Compare versions in PostgreSQL
November 25, 2021 — Ulysse
Yesterday, one of my colleagues asked me for a version comparison trick in SQL. I gave him a very basic one, and looked a bit the internet for a simple pure SQL solution to do easily a bit more than that. Nothing convinced me really, hence this blog post and implementation.
When using semantic versioning (MAJOR.MINOR.PATCH
), there are some nice tools
to compare a given version with a requirement, for instance, ruby’s Gemfile
syntax goes like:
# Minor and patch version can only be greater or
# equal to MAJOR.3.0.
# Major must be 2.
gem "fast-polylines", "~> 2.3"
# Only the patch version can be greater
# than the expected one.
gem "hiredis", "~> 0.6.3"
# Only patch version may vary.
gem "oj", "= 3.13"
# Any version greater or equal to 3.
gem "rgeo", ">= 3"
This can also be used in code directly:
Gem::Version.new("10.0.1") > Gem::Version.new("10.0.0") # true
See the ruby documentation for a more in-depth explanation of those operators.
But what if you had a table with a version (text) column in your SQL? Well,
fortunately in PostgreSQL, there is a nice string_to_array
function which lets
us split our version easily:
SELECT string_to_array('4.2.0', '.'); -- {4,2,0}
And we can easily convert our resulting array to integers to simplify later computation:
SELECT ARRAY['4', '2']::int[];
Since in PostgreSQL array comparison is done by comparing each pair consecutively, we are already very close to a complete solution:
SELECT ARRAY[2, 1] > ARRAY[2, 0]; -- true
SELECT ARRAY[2, 1] > ARRAY[2]; -- true
combining what we have already, it is quite easy to do basic comparison between two versions now:
SELECT string_to_array('6.6.6', '.')::int[] > string_to_array('6.5', '.')::int[]
However, what if we wanted to compare using ruby like ~>
comparison? We’ll have
to dig a tiny bit deeper.
So ~> X.Y.Z
basically means that the expected version should be:
>= X.Y.Z
< X.Y.(Z+1)
And this apply to a comparison without patch as well, ~> X.Y
implies:
>= X.Y
< X.(Y+1)
Hence we could just write:
SELECT string_to_array('6.6.6', '.')::int[] >= string_to_array('6.6.6', '.')::int[]
AND string_to_array('6.6.6', '.')::int[] < string_to_array('6.6.7', '.')::int[]
However, I’d like to be able to express this comparison in a single line:
SELECT semver_match(ver, req)
FROM (VALUES ('6.6.6', '~> 6.6.6')) AS _ (ver, req);
So how can we can we compar our ver
with both >= 6.6.6
and < 6.6.7
? Feel
free to pause there and find your own solution!
The first comparison is trivial, we just have to parse our 6.6.6
text. The
second one is a bit harder since we have to somehow increment our array. There is no postgres operator that lets us mutate an array in place, except when
setting a value. Hence I went for a more functional way: consider the n - 1
first elements of my array, and using array_append
to concatenate the last value, incremented:
SELECT array_append(
(ARRAY[4,2,1])[1:(array_length(ARRAY[4,2,1], 1) - 1)], -- X.Y
(ARRAY[4,2,1])[array_length(ARRAY[4,2,1], 1)] + 1 -- Z + 1
)
Once we have that, we can use a CASE
statement to see which operator was
used, and the rest is just a bit of syntax:
-- https://ulysse.md
-- example usage: semver_match('4.2.1', '~> 4.2.0').
CREATE OR REPLACE FUNCTION semver_match(version text, req text) RETURNS boolean
LANGUAGE SQL
IMMUTABLE
RETURNS NULL ON NULL INPUT
AS $$
SELECT CASE
WHEN req LIKE '~>%' THEN
string_to_array(version, '.')::int[] >= string_to_array(substring(req from 4), '.')::int[]
AND
string_to_array(version, '.')::int[] <
-- increment last item by one. (X.Y.Z => X.Y.(Z+1))
array_append(
(string_to_array(substring(req from 4), '.')::int[])[1:(array_length(string_to_array(req, '.'), 1) - 1)], -- X.Y
(string_to_array(substring(req from 4), '.')::int[])[array_length(string_to_array(req, '.'), 1)] + 1 -- Z + 1
)
WHEN req LIKE '>=%' THEN string_to_array(version, '.')::int[] >= string_to_array(substring(req from 4), '.')::int[]
WHEN req LIKE '<=%' THEN string_to_array(version, '.')::int[] <= string_to_array(substring(req from 4), '.')::int[]
WHEN req LIKE '>%' THEN string_to_array(version, '.')::int[] > string_to_array(substring(req from 3), '.')::int[]
WHEN req LIKE '<%' THEN string_to_array(version, '.')::int[] < string_to_array(substring(req from 3), '.')::int[]
WHEN req LIKE '=%' THEN
(string_to_array(version, '.')::int[])[1:array_length(string_to_array(substring(req from 3), '.'), 1)] =
string_to_array(substring(req from 3), '.')::int[]
ELSE NULL
END $$;
Now we only have left to test our function:
-- tests.
SELECT
ver,
req,
CASE WHEN semver_match(ver, req) = expected
THEN '✅' ELSE '❌' END AS test_passed
FROM (VALUES
('2.3', '>= 2.3', TRUE),
('2.3.1', '> 2.3', TRUE),
('2.3.1', '< 2.3.2', TRUE),
('2.3.1', '~> 2.3.2', FALSE),
('2.4.3', '~> 2.3.2', FALSE),
('2.3.2', '~> 2.3.2', TRUE),
('2.3.2', '= 2.3.2', TRUE),
('2.3.2', '= 2.3', TRUE),
('2.3.2', '= 2.4', FALSE)
) AS _ (ver, req, expected);
The whole code is also available on StackOverflow, feel free to improve or ping me in the comments for suggestions!