PostgreSQL has a lot of secret data types. In recent times, PostgreSQL’s JSON and JSONB support was hyped as being the NoSQL on SQL secret (e.g. as advertised by ToroDB) that allows you to get the best out of both worlds. But there are many other useful data types, among which the range type.
How does the range type work?
Ranges are very useful for things like age, date, price intervals etc. Let’s assume the following table:
CREATE TABLE age_categories (
name VARCHAR(50),
ages INT4RANGE
);
We can now fill the above table as such:
INSERT INTO age_categories
VALUES ('Foetus', int4range(-1, 0)),
('Newborn', int4range(0, 1)),
('Infant', int4range(1, 2)),
('Kid', int4range(2, 12)),
('Teenager', int4range(12, 20)),
('Tween', int4range(20, 30)),
('Adult', int4range(30, 60)),
('Senior', int4range(60, 90)),
('Ancient', int4range(90, 999));
And query it, e.g. by taking my age:
SELECT name
FROM age_categories
WHERE range_contains_elem(ages, 33);
… yielding
name ----- Adult
There are a lot of other useful functions involved with range calculations. For instance, we can get the age span of a set of categories. Let’s assume we want to have the age span of Kid, Teenager, Senior
. Let’s query:
SELECT int4range(min(lower(ages)), max(upper(ages)))
FROM age_categories
WHERE name IN ('Kid', 'Teenager', 'Senior');
… yielding
int4range --------- [2,90]
And now, let’s go back to fetching all the categories that are within that range:
SELECT name
FROM age_categories
WHERE range_overlaps(ages, (
SELECT int4range(min(lower(ages)), max(upper(ages)))
FROM age_categories
WHERE name IN ('Kid', 'Teenager', 'Senior')
));
… yielding
name -------- Kid Teenager Tween Adult Senior
All of this could have been implemented with values contained in two separate columns, but using ranges is just much more expressive for range arithmetic.
How to use these types with jOOQ?
jOOQ doesn’t include built-in support for these advanced data types, but it allows you to bind these data types to your own, custom representation.
A good representation of PostgreSQL’s range types in Java would be jOOλ’s org.jooq.lambda.tuple.Range
type, but you could also simply use int[]
or Map.Entry
for ranges. When we’re using jOOλ’s Range
type, the idea is to be able to run the following statements using jOOQ:
// Assuming this static import:
import static org.jooq.lambda.tuple.Tuple.*;
DSL.using(configuration)
.insertInto(AGE_CATEGORIES)
.columns(AGE_CATEGORIES.NAME, AGE_CATEGORIES.AGES)
.values("Foetus", range(-1, 0))
.values("Newborn", range(0, 1))
.values("Infant", range(1, 2))
.values("Kid", range(2, 12))
.values("Teenager", range(12, 20))
.values("Tween", range(20, 30))
.values("Adult", range(30, 60))
.values("Senior", range(60, 90))
.values("Ancient", range(90, 999))
.execute();
And querying…
DSL.using(configuration)
.select(AGE_CATEGORIES.NAME)
.from(AGE_CATEGORIES)
.where(rangeContainsElem(AGE_CATEGORIES.AGES, 33))
.fetch();
DSL.using(configuration)
.select(AGE_CATEGORIES.NAME)
.where(rangeOverlaps(AGE_CATEGORIES.AGES,
select(int4range(min(lower(AGE_CATEGORIES.AGES)),
max(upper(AGE_CATEGORIES.AGES))))
.from(AGE_CATEGORIES)
.where(AGE_CATEGORIES.NAME.in(
"Kid", "Teenager", "Senior"
))
))
.fetch();
As always, the idea with jOOQ is that the SQL you want to get as output is the SQL you want to write in Java, type safely. In order to be able to write the above, we will need to implement 1-2 missing pieces. First off, we’ll need to create a data type binding (org.jooq.Binding
) as described in this section of the manual. The binding can be written as such, using the following Converter
:
public class Int4RangeConverter
implements Converter<Object, Range<Integer>> {
private static final Pattern PATTERN =
Pattern.compile("\\[(.*?),(.*?)\\)");
@Override
public Range<Integer> from(Object t) {
if (t == null)
return null;
Matcher m = PATTERN.matcher("" + t);
if (m.find())
return Tuple.range(
Integer.valueOf(m.group(1)),
Integer.valueOf(m.group(2)));
throw new IllegalArgumentException(
"Unsupported range : " + t);
}
@Override
public Object to(Range<Integer> u) {
return u == null
? null
: "[" + u.v1 + "," + u.v2 + ")";
}
@Override
public Class<Object> fromType() {
return Object.class;
}
@SuppressWarnings({ "unchecked", "rawtypes" })
@Override
public Class<Range<Integer>> toType() {
return (Class) Range.class;
}
}
… and the Converter
can then be re-used in a Binding
:
public class PostgresInt4RangeBinding
implements Binding<Object, Range<Integer>> {
@Override
public Converter<Object, Range<Integer>> converter() {
return new Int4RangeConverter();
}
@Override
public void sql(BindingSQLContext<Range<Integer>> ctx) throws SQLException {
ctx.render()
.visit(DSL.val(ctx.convert(converter()).value()))
.sql("::int4range");
}
// ...
}
The important thing in the binding is just that every bind variable needs to be encoded in PostgreSQL’s string format for range types (i.e. [lower, upper)
) and cast explicitly to ?::int4range
, that’s all.
You can then configure your code generator to use those types, e.g. on all columns that are called [xxx]_RANGE
<customType>
<name>com.example.PostgresInt4RangeBinding</name>
<type>org.jooq.lambda.tuple.Range<Integer></type>
<binding>com.example.PostgresInt4RangeBinding</binding>
</customType>
<forcedType>
<name>com.example.PostgresInt4RangeBinding</name>
<expression>.*?_RANGE</expression>
</forcedType>
The last missing thing now are the functions that you need to compare ranges, i.e.:
rangeContainsElem()
rangeOverlaps()
int4range()
lower()
upper()
These are written quickly:
static <T extends Comparable<T>> Condition
rangeContainsElem(Field<Range<T>> f1, T e) {
return DSL.condition("range_contains_elem({0}, {1})", f1, val(e));
}
static <T extends Comparable<T>> Condition
rangeOverlaps(Field<Range<T>> f1, Range<T> f2) {
return DSL.condition("range_overlaps({0}, {1})", f1, val(f2, f1.getDataType()));
}
Conclusion
Writing an extension for jOOQ data types takes a bit of time and effort, but it is really easy to achieve and will allow you to write very powerful queries in a type safe way. Once you’ve set up all the data types and the bindings, your generated source code will reflect the actual data type from your database and you will be able to write powerful queries in a type safe way directly in Java. Let’s consider again the plain SQL and the jOOQ version:
SQL
SELECT name
FROM age_categories
WHERE range_overlaps(ages, (
SELECT int4range(min(lower(ages)), max(upper(ages)))
FROM age_categories
WHERE name IN ('Kid', 'Teenager', 'Senior')
));
jOOQ
DSL.using(configuration)
.select(AGE_CATEGORIES.NAME)
.where(rangeOverlaps(AGE_CATEGORIES.AGES,
select(int4range(min(lower(AGE_CATEGORIES.AGES)),
max(upper(AGE_CATEGORIES.AGES))))
.from(AGE_CATEGORIES)
.where(AGE_CATEGORIES.NAME.in(
"Kid", "Teenager", "Senior"
))
))
.fetch();
More information about data type bindings can be found in the jOOQ manual.
Great article! I inspired by it and prepared similar code for Postgres DATERANGE type. But I found out that lookup for range that contains single date is extremely slow despite GIST index on range field. According to analyze command index was not used at all. I had to change implementation of rangeContainsElem to:
return DSL.condition("{0} @> {1}::date", f1, DSL.val(e));
That way it uses index and it is 20x faster that my previous solution with two date fields.
Thank you very much for sharing. That’s a bummer. Would be interesting as a Stack Overflow question to get some insight regarding why that is the case…
Done: https://stackoverflow.com/questions/41975548/daterange-index-not-used-when-lookup-is-done-by-range-contains-elem-function :-)
Thank you very much. Looking forward to answers!
Hello Lukas, that’s a nice feature thank you.
One limitation for me of your Range class: bounds can’t be null (I’m talking about this: https://github.com/jOOQ/jOOL/blob/db26c4bb9513348e7512f346130553d4e1dd3c6f/jOOL-java-8/src/main/java/org/jooq/lambda/tuple/Range.java#L38).
In PostgreSQL it’s authorized and has meaning: for example a null start bound means ‘-infinity’, which is especially useful when using time ranges.
What do you think?
In the meantime I’ve created a NullableBoundRange class in my project, and if you think it’s worth it I would love to make a pull request on the repo.
Artus
Thanks for your suggestion, Artus. I don’t disagree, this could be useful. Would you be interested in providing a pull request for this issue? https://github.com/jOOQ/jOOL/issues/350
The change might be quite simple, I’m curious about how you’d see this being used in 3-4 unit tests…
Thanks for the response, I’ll submit my changes as soon as I can