This is how SQL should have been used all along.
They called it The Third Manifesto, ORDBMS, or other things. Regrettably, it never really took off. Because most vendors didn’t adopt it. And those who did, didn’t agree on syntax.
But this is about to change. Thanks to the now ubiquitous SQL/JSON support (which jOOQ 3.14 has already covered), we can now emulate the most powerful ORDBMS feature that you will want to use everywhere: Nested collections!
How We Used to do Things: With Joins
We’re going to be using the Sakila database for this example. It’s a DVD rental store with things like ACTOR
, FILM
, CATEGORY
(of films) and other nice relational things. Let’s write a query for this requirement
Get me all the films with their actors and their categories
Classically, we’d go ahead and use jOOQ to write:
ctx.select(
FILM.TITLE,
ACTOR.FIRST_NAME,
ACTOR.LAST_NAME,
CATEGORY.NAME
)
.from(ACTOR)
.join(FILM_ACTOR)
.on(ACTOR.ACTOR_ID.eq(FILM_ACTOR.ACTOR_ID))
.join(FILM)
.on(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
.join(FILM_CATEGORY)
.on(FILM.FILM_ID.eq(FILM_CATEGORY.FILM_ID))
.join(CATEGORY)
.on(FILM_CATEGORY.CATEGORY_ID.eq(CATEGORY.CATEGORY_ID))
.orderBy(1, 2, 3, 4)
.fetch();
And the result? Not so nice. A denormalised, flat table containing tons of repetition:
+----------------+----------+---------+-----------+ |title |first_name|last_name|name | +----------------+----------+---------+-----------+ |ACADEMY DINOSAUR|CHRISTIAN |GABLE |Documentary| |ACADEMY DINOSAUR|JOHNNY |CAGE |Documentary| |ACADEMY DINOSAUR|LUCILLE |TRACY |Documentary| |ACADEMY DINOSAUR|MARY |KEITEL |Documentary| |ACADEMY DINOSAUR|MENA |TEMPLE |Documentary| |ACADEMY DINOSAUR|OPRAH |KILMER |Documentary| |ACADEMY DINOSAUR|PENELOPE |GUINESS |Documentary| |ACADEMY DINOSAUR|ROCK |DUKAKIS |Documentary| |ACADEMY DINOSAUR|SANDRA |PECK |Documentary| |ACADEMY DINOSAUR|WARREN |NOLTE |Documentary| |ACE GOLDFINGER |BOB |FAWCETT |Horror | |ACE GOLDFINGER |CHRIS |DEPP |Horror | |ACE GOLDFINGER |MINNIE |ZELLWEGER|Horror | ...
If we don’t consume this result unmodified as it is (e.g. when displaying tabular data to a user), we’d then go and de-duplicate things, shoehorning them back again into some nested data structures (e.g. for consumption by some JSON based UI), and spending hours trying to untangle the cartesian products between the 2 nested collections FILM
-> ACTOR
and FILM
-> CATEGORY
(because ACTOR
and CATEGORY
now created a cartesian product, which we didn’t want!)
In the worst case, we don’t even notice! This example database only has 1 category per film, but it is designed to support multiple categories.
jOOQ can help with that deduplication, but just look at the number of questions for jOOQ many to many on Stack Overflow! You’ll probably still have to write at least 2 queries to separate the nested collections.
ENTER the Stage: Multiset
The standard SQL <multiset value constructor>
operator allows for collecting the data from a correlated subquery into a nested data structure, a MULTISET
. Everything in SQL is a MULTISET
, so the operator isn’t too surprising. But the nesting is where it shines. The previous query can now be re-written in jOOQ as follows:
var result =
dsl.select(
FILM.TITLE,
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
).as("actors"),
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
).as("films")
)
.from(FILM)
.orderBy(FILM.TITLE)
.fetch();
Note, it’s not relevant to this task, but I’m using jOOQ’s type safe implicit to-one join feature, which further helps taming the joins, syntactically. A matter of taste.
How to read this query? Easy:
- Get all the films
- For each
FILM
, get all the actors as a nested collection - For each
FILM
, get all the categories as a nested collection
You’re going to like Java 10’s var
keyword even more after this :) Because what type is result
? It’s of this type:
Result<Record3<
String, // FILM.TITLE
Result<Record2<String, String>>, // ACTOR.FIRST_NAME, ACTOR.LAST_NAME
Result<Record1<String>> // CATEGORY.NAME
>>
That’s quite something. Not too complex if you think of it. There’s a result with 3 columns
TITLE
- A first nested result that has 2 string columns:
ACTOR.FIRST_NAME
andACTOR.LAST_NAME
- A second nested result that has 1 string column:
CATEGORY.NAME
Using var
or other type inference mechanisms, you don’t have to denote this type. Or even better (stay tuned): We’ll type-safely map the structural type to our nominal DTO type hierarchy, with just a few additional lines of code. I’ll explain that later.
What Does the Result Look Like?
Calling toString()
on the above Result
type yields something like this:
+---------------------------+--------------------------------------------------+---------------+ |title |actors |films | +---------------------------+--------------------------------------------------+---------------+ |ACADEMY DINOSAUR |[(PENELOPE, GUINESS), (CHRISTIAN, GABLE), (LUCI...|[(Documentary)]| |ACE GOLDFINGER |[(BOB, FAWCETT), (MINNIE, ZELLWEGER), (SEAN, GU...|[(Horror)] | |ADAPTATION HOLES |[(NICK, WAHLBERG), (BOB, FAWCETT), (CAMERON, ST...|[(Documentary)]| |AFFAIR PREJUDICE |[(JODIE, DEGENERES), (SCARLETT, DAMON), (KENNET...|[(Horror)] | |AFRICAN EGG |[(GARY, PHOENIX), (DUSTIN, TAUTOU), (MATTHEW, L...|[(Family)] | |AGENT TRUMAN |[(KIRSTEN, PALTROW), (SANDRA, KILMER), (JAYNE, ...|[(Foreign)] | ...
Notice how we’re back to getting each FILM.TITLE
entry only once (no duplication), and nested in each row are the subquery results. There’s no denormalisation happening!
When calling result.formatJSON()
with the appropriate formatting options, we’ll get this representation:
[ { "title": "ACADEMY DINOSAUR", "actors": [ { "first_name": "PENELOPE", "last_name": "GUINESS" }, { "first_name": "CHRISTIAN", "last_name": "GABLE" }, { "first_name": "LUCILLE", "last_name": "TRACY" }, { "first_name": "SANDRA", "last_name": "PECK" }, ... ], "films": [ { "name": "Documentary" } ] }, { "title": "ACE GOLDFINGER", "actors": [ { "first_name": "BOB", "last_name": "FAWCETT" }, ...
Calling result.formatXML()
would produce this:
<result> <record> <title>ACADEMY DINOSAUR</title> <actors> <result> <record> <first_name>PENELOPE</first_name> <last_name>GUINESS</last_name> </record> <record> <first_name>CHRISTIAN</first_name> <last_name>GABLE</last_name> </record> <record> <first_name>LUCILLE</first_name> <last_name>TRACY</last_name> </record> <record> <first_name>SANDRA</first_name> <last_name>PECK</last_name> </record> ... </result> </actors> <films> <result> <record> <name>Documentary</name> </record> </result> </films> </record> <record> <title>ACE GOLDFINGER</title> <actors> <result> <record> <first_name>BOB</first_name> <last_name>FAWCETT</last_name> </record> ...
You get the idea!
What’s the Generated SQL?
Just turn on jOOQ’s DEBUG
logging and observe a query like this one (in PostgreSQL):
select
film.title,
(
select coalesce(
jsonb_agg(jsonb_build_object(
'first_name', t.first_name,
'last_name', t.last_name
)),
jsonb_build_array()
)
from (
select
alias_78509018.first_name,
alias_78509018.last_name
from (
film_actor
join actor as alias_78509018
on film_actor.actor_id = alias_78509018.actor_id
)
where film_actor.film_id = film.film_id
) as t
) as actors,
(
select coalesce(
jsonb_agg(jsonb_build_object('name', t.name)),
jsonb_build_array()
)
from (
select alias_130639425.name
from (
film_category
join category as alias_130639425
on film_category.category_id = alias_130639425.category_id
)
where film_category.film_id = film.film_id
) as t
) as films
from film
order by film.title
The Db2, MySQL, Oracle, SQL Server versions would look similar. Just try it on your Sakila database installation. It runs fast, too.
Mapping the Results to DTOs
Now, I promised to get rid of that lengthy structural type with all the generics. Check this out!
We used to call them POJOs (Plain Old Java Objects). Then DTOs (Data Transfer Objects). Now records. Yes, let’s try some Java 16 records here. (Note, records aren’t required for these examples. Any POJOs with appropriate constructors would do).
Wouldn’t it be nice, if result
was of this type
record Actor(String firstName, String lastName) {}
record Film(
String title,
List<Actor> actors,
List<String> categories
) {}
List<Film> result = ...
Structural typing is essential to jOOQ and its type safe query system, but in your code, you probably don’t want to have those merge-conflict lookalike generics all the time, and even var
won’t help you if your data needs to be returned from a method.
So, let’s transform our jOOQ query to one that produces List<Film>
, step by step. We’re starting with the original query, untouched:
Result<Record3<
String,
Result<Record2<String, String>>,
Result<Record1<String>>
>> result =
dsl.select(
FILM.TITLE,
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
).as("actors"),
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
).as("films")
)
.from(FILM)
.orderBy(FILM.TITLE)
.fetch();
Now, we’re going to map the Actor
in the first MULTISET
expression. This can be done as follows, using the new Field.convertFrom()
convenience method, which allows to turn a Field<T>
into any read-only Field<U>
for ad-hoc usage. A simple example would be this:
record Title(String title) {}
// Use this field in any query
Field<Title> title = FILM.TITLE.convertFrom(Title::new);
It’s just an easy, new way to attach a read-only Converter
to a Field
for single usage, instead of doing that with the code generator.
Applied to the original query:
Result<Record3<
String,
List<Actor>, // A bit nicer already
Result<Record1<String>>
>> result =
dsl.select(
FILM.TITLE,
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
// Magic here: vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
).as("actors").convertFrom(r -> r.map(mapping(Actor::new))),
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
).as("films")
)
.from(FILM)
.orderBy(FILM.TITLE)
.fetch();
What are we doing here?
- The method
convertFrom()
takes a lambdaResult<Record2<String, String>> -> Actor
. - The
Result
type is the usual jOOQResult
, which has aResult.map(RecordMapper<R, E>)
method. - The
mapping()
method is the newRecords.mapping()
, which isn’t doing much, just turning a constructor reference of typeFunction2<String, String, Actor>
into aRecordMapper
, which can then be used to turn aResult<Record2<String, String>>
into aList<Actor>
.
And it type checks! Try it yourself. If you add a column to the multiset, you’ll get a compilation error. If you add/remove an attribute from the Actor
record, you’ll get a compilation error. No reflection here, just declarative mapping of jOOQ results/records to custom List<UserType>
. If you prefer the “old” reflection approach using jOOQ’s ubiquitous into()
methods, you can still do that, too:
Result<Record3<
String,
List<Actor>, // A bit nicer already
Result<Record1<String>>
>> result =
dsl.select(
FILM.TITLE,
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
// Magic here: vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
).as("actors").convertFrom(r -> r.into(Actor.class))),
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
).as("films")
)
.from(FILM)
.orderBy(FILM.TITLE)
.fetch();
The result still type checks, but the conversion from Result<Record2<String, String>>
to List<Actor>
no longer does, it uses reflection.
Let’s continue. Let’s remove the clumsy category Result<Record1<String>>
. We could’ve added another record, but in this case, a List<String>
will suffice.
Result<Record3<
String,
List<Actor>,
List<String> // Begone, jOOQ structural type!
>> result =
dsl.select(
FILM.TITLE,
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
).as("actors").convertFrom(r -> r.map(mapping(Actor::new))),
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
// Magic. vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
).as("films").convertFrom(r -> r.map(Record1::value1))
)
.from(FILM)
.orderBy(FILM.TITLE)
.fetch();
And finally, the outer-most Result<Record3<...>>
to List<Film>
conversion
List<Film> result =
dsl.select(
FILM.TITLE,
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
).as("actors").convertFrom(r -> r.map(mapping(Actor::new))),
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
).as("films").convertFrom(r -> r.map(Record1::value1))
)
.from(FILM)
.orderBy(FILM.TITLE)
// vvvvvvvvvvvvvvvvvv grande finale
.fetch(mapping(Film::new));
This time, we don’t need the Field.convertFrom()
method. Just using the Records.mapping()
auxiliary will be sufficient.
A More Complex Example
The previous example showed nesting of two independent collections, which is quite hard with classic JOIN
based SQL or ORMs. How about a much more complex example, where we nest things 2 levels, one of which being an aggregation, even? The requirement is:
Give me all the films, the actors that played in the film, the categories that categorise the film, the customers that have rented the film, and all the payments per customer for that film
I won’t even show a JOIN
based approach. Let’s dive directly into MULTISET
and the also new, synthetic MULTISET_AGG
aggregate function. Here’s how to do this with jOOQ. Now, check out that beautiful result type:
Result<Record4<
String, // FILM.TITLE
Result<Record2<
String, // ACTOR.FIRST_NAME
String // ACTOR.LAST_NAME
>>, // "actors"
Result<Record1<String>>, // CATEGORY.NAME
Result<Record4<
String, // CUSTOMER.FIRST_NAME
String, // CUSTOMER.LAST_NAME
Result<Record2<
LocalDateTime, // PAYMENT.PAYMENT_DATE
BigDecimal // PAYMENT.AMOUNT
>>,
BigDecimal // "total"
>> // "customers"
>> result =
dsl.select(
// Get the films
FILM.TITLE,
// ... and all actors that played in the film
multiset(
select(
FILM_ACTOR.actor().FIRST_NAME,
FILM_ACTOR.actor().LAST_NAME
)
.from(FILM_ACTOR)
.where(FILM_ACTOR.FILM_ID.eq(FILM.FILM_ID))
).as("actors"),
// ... and all categories that categorise the film
multiset(
select(FILM_CATEGORY.category().NAME)
.from(FILM_CATEGORY)
.where(FILM_CATEGORY.FILM_ID.eq(FILM.FILM_ID))
).as("categories"),
// ... and all customers who rented the film, as well
// as their payments
multiset(
select(
PAYMENT.rental().customer().FIRST_NAME,
PAYMENT.rental().customer().LAST_NAME,
multisetAgg(
PAYMENT.PAYMENT_DATE,
PAYMENT.AMOUNT
).as("payments"),
sum(PAYMENT.AMOUNT).as("total"))
.from(PAYMENT)
.where(PAYMENT
.rental().inventory().FILM_ID.eq(FILM.FILM_ID))
.groupBy(
PAYMENT.rental().customer().CUSTOMER_ID,
PAYMENT.rental().customer().FIRST_NAME,
PAYMENT.rental().customer().LAST_NAME)
).as("customers")
)
.from(FILM)
.where(FILM.TITLE.like("A%"))
.orderBy(FILM.TITLE)
.limit(5)
.fetch();
You’ll be using var
, of course, rather than denoting this insane type but I wanted to denote the type explicitly for the sake of the example.
Note again how implicit joins were really helpful here since we wanted to aggregate all the payments per customer, we can just select from PAYMENT
and group by the payment’s PAYMENT.rental().customer()
, as well as correlate the subquery by PAYMENT.rental().inventory().FILM_ID
without any extra effort.
The executed SQL looks like this, where you can see the generated implicit joins (run it on your PostgreSQL Sakila database!):
select
film.title,
(
select coalesce(
jsonb_agg(jsonb_build_object(
'first_name', t.first_name,
'last_name', t.last_name
)),
jsonb_build_array()
)
from (
select alias_78509018.first_name, alias_78509018.last_name
from (
film_actor
join actor as alias_78509018
on film_actor.actor_id = alias_78509018.actor_id
)
where film_actor.film_id = film.film_id
) as t
) as actors,
(
select coalesce(
jsonb_agg(jsonb_build_object('name', t.name)),
jsonb_build_array()
)
from (
select alias_130639425.name
from (
film_category
join category as alias_130639425
on film_category.category_id =
alias_130639425.category_id
)
where film_category.film_id = film.film_id
) as t
) as categories,
(
select coalesce(
jsonb_agg(jsonb_build_object(
'first_name', t.first_name,
'last_name', t.last_name,
'payments', t.payments,
'total', t.total
)),
jsonb_build_array()
)
from (
select
alias_63965917.first_name,
alias_63965917.last_name,
jsonb_agg(jsonb_build_object(
'payment_date', payment.payment_date,
'amount', payment.amount
)) as payments,
sum(payment.amount) as total
from (
payment
join (
rental as alias_102068213
join customer as alias_63965917
on alias_102068213.customer_id =
alias_63965917.customer_id
join inventory as alias_116526225
on alias_102068213.inventory_id =
alias_116526225.inventory_id
)
on payment.rental_id = alias_102068213.rental_id
)
where alias_116526225.film_id = film.film_id
group by
alias_63965917.customer_id,
alias_63965917.first_name,
alias_63965917.last_name
) as t
) as customers
from film
where film.title like 'A%'
order by film.title
fetch next 5 rows only
The result, in JSON, now looks something like this:
[ { "title": "ACADEMY DINOSAUR", "actors": [ { "first_name": "PENELOPE", "last_name": "GUINESS" }, { "first_name": "CHRISTIAN", "last_name": "GABLE" }, { "first_name": "LUCILLE", "last_name": "TRACY" }, ... ], "categories": [{ "name": "Documentary" }], "customers": [ { "first_name": "SUSAN", "last_name": "WILSON", "payments": [ { "payment_date": "2005-07-31T22:08:29", "amount": 0.99 } ], "total": 0.99 }, { "first_name": "REBECCA", "last_name": "SCOTT", "payments": [ { "payment_date": "2005-08-18T18:36:16", "amount": 0.99 } ], "total": 0.99 }, ...
That’s it. Nesting collections in arbitrary ways is completely effortless and intuitive. No N+1, no deduplication. Just declare the results in exactly the form you require in your client.
There is no other way to pull off this complexity with such ease, than letting your RDBMS do the heavy lifting of planning and running such a query, and letting jOOQ do the mapping.
Conclusion
We had this kind of functionality all along. We just never used it, or not enough. Why? Because client APIs did not make it accessible enough. Because RDBMS didn’t agree on syntax enough.
That’s now over. jOOQ uses standard SQL MULTISET
syntax in its DSL API, enhances it with the synthetic MULTISET_AGG
aggregate function, the way all RDBMS should have implemented it (go Informix, Oracle). We can wait for another 40 years for the other RDBMS to implement this, or we just use jOOQ today.
And, I cannot stress this enough:
- This is all type safe
- There is no reflection
- The nesting is done in the database using SQL (via SQL/XML or SQL/JSON for now)
- … So, execution planners can optimise your entire query
- … No extra columns or extra queries or other extra work is performed in the database
This works on all dialects that have either SQL/XML or SQL/JSON support (or both), including the major popular dialects:
- MySQL
- Oracle
- PostgreSQL
- SQL Server
And it is offered under the usual license terms of jOOQ. So, happy nesting of collections.
Addendum: Using SQL/XML or SQL/JSON directly
You may be tempted to use this everywhere. And you rightfully do so. But beware of this, if your SQL client is consuming XML or JSON directly, there’s no need to use MULTISET
. Use jOOQ’s native SQL/XML or SQL/JSON support that was introduced in jOOQ 3.14. That way, you won’t convert from JSON to jOOQ results to JSON, but stream the JSON (or XML) to your frontend directly.
from Java, SQL and jOOQ. https://ift.tt/3hEnOHE
via IFTTT
No comments:
Post a Comment