Two Kinds of SQL Query Builders
26 points by pushcx
26 points by pushcx
I read through the post and found myself nodding along. But I was quite surprised to reach the end without any discussion of Bigquery's pipe syntax [1]. While not a silver bullet, IMO, databases adding support for pipe sql would take a big step forward towards resolving a lot of the criticisms levelled against SQL, both by this article and in general.
[1] https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/pipe-syntax
Pipe syntax was a real "aha!" moment for me. I'm reasonably proficient at SQL, but pipelines make things so much more composable. It's really easy to just comment/uncomment parts of a query (like putting a LIMIT or WHERE in for quick testing) without having to edit anything else.
(I work at Google, but my enthusiasm for language syntax transcends employment)
tl;dr: A description of 2 almost-pipe syntaxes.
For declarative-dsls, I have thought about enabling the order of arguments to be meaningful (i.e. pipe syntax without pipes) (it would actually simplify the parser, currently you can put things in basically any order), so this works today:
(df/select :id :from person
:where |(= ($ :gender-concept-id) 8507)
:sort < :year-of-birth
:limit 100)
There's also an approach with -> used by e.g. tech.ml or Qi which I, again, don't like much. Using a macro, something like this would execute everything efficiently (but most of these are privately defined apply- at the moment, so it won't work):
(->> person # random convoluted example with multiple :where
(df/where {:gender-concept-id 8507})
(df/join visit-occurrence)
(df/where |(>= ($ :visit-start-year) 2020))
(df/select :id [[:visit-start-year :year-of-birth] :age-at-visit
|(- ($ :visit-start-year) ($ :year-of-birth))])
(df/where |(>= ($ :age-at-visit) 65))
(df/exclude death)
(df/sort > :age-at-visit)
(df/select :id)
(df/distinct true)
(df/limit 100))
->>` already works, for example, given this data:
(def [person visit-occurrence death] [@{:id @[1 2 3 4 5 6 7] :gender-concept-id @[8507 8507 8532 8507 8507 8507 8507] :year-of-birth @[1940 1970 1945 1950 1935 1948 1955]} @{:visit-id @[101 102 103 104 105 106 107 108 109] :id @[1 1 2 3 4 4 5 7 7] :visit-start-year @[2021 2023 2022 2021 2019 2021 2020 2020 2024]} @{:id @[5] :death-year @[2022]}])
You can do:
(import declarative-dsls :as df)
(->> person
(df/select :where {:gender-concept-id 8507} :from)
(df/select :join visit-occurrence :from)
(df/select :where |(>= ($ :visit-start-year) 2020) :from)
(df/select :id [[:visit-start-year :year-of-birth] :age-at-visit
|(- ($ :visit-start-year) ($ :year-of-birth))] :from)
(df/select :where |(>= ($ :age-at-visit) 65) :from)
(df/select :exclude death :from)
(df/select :sort > :by [:age-at-visit] :from)
(df/select :id :from)
(df/select :distinct true :from)
(df/select :limit 100 :from))
# Or:
(->> person
(df/select :id [[:visit-start-year :year-of-birth] :age-at-visit
|(- ($ :visit-start-year) ($ :year-of-birth))]
:join visit-occurrence
:where {:gender-concept-id 8507}
:where |(>= ($ :visit-start-year) 2020)
:from) # multiple where already work in order!
(df/select :id # but the join makes us need a new select
:where |(>= ($ :age-at-visit) 65)
:exclude death
:sort > :by [:age-at-visit]
:distinct [:id]
:limit 100
:from))
...should I expose the functions directly to allow the first thing? Do people like such an API? I think letting the order be significant is best (but also giving a default select which handles it for you; easier for users, maybe.)
My understanding from SQL Has Problems. We Can Fix Them: Pipe Syntax In SQL (2024) (previously (3 comments) and previouslyier (31 comments)) is that the pipe syntax is an SQL native data-oriented query builder.
The first example in the paper is translating the following trad-SQL query:
SELECT c_count, COUNT(*) AS custdist
FROM
( SELECT c_custkey, COUNT(o_orderkey) c_count
FROM customer
LEFT OUTER JOIN orders ON c_custkey = o_custkey
AND o_comment NOT LIKE '%unusual%packages%'
GROUP BY c_custkey
) AS c_orders
GROUP BY c_count
ORDER BY custdist DESC, c_count DESC;
In the equivalent in pipe syntax:
FROM customer
|> LEFT OUTER JOIN orders ON c_custkey = o_custkey
AND o_comment NOT LIKE '%unusual%packages%'
|> AGGREGATE COUNT(o_orderkey) c_count
GROUP BY c_custkey
|> AGGREGATE COUNT(*) AS custdist
GROUP BY c_count
|> ORDER BY custdist DESC, c_count DESC;
Similarly in the blog post, the following:
SELECT "person_2"."person_id"
FROM (
SELECT
"person_1"."person_id",
"person_1"."gender_concept_id"
FROM "person" AS "person_1"
ORDER BY "person_1"."year_of_birth"
LIMIT 100
) AS "person_2"
WHERE ("person_2"."gender_concept_id" = 8507)
In the equivalent data-oriented FunSQL:
From(:person) |>
Where(Get.gender_concept_id .== 8507) |>
Order(Get.year_of_birth) |>
Limit(100) |>
Select(Get.person_id)
n.b. the expanded sub-queries.
Something that struck me reading the article is in fact how similar the different examples of query builders are to plain SQL. Which begs the question, why use them in the first place? I mean, on the one hand those DSLs actually require us to be knowledgeable in SQL. On the other hand they prevent using more advanced features of SQL such as CTEs, window functions, RETURNING clauses, etc. Those abstractions are quite leaky. The way those WHERE clauses are put together in some of the examples. Sure, SQL has some warts, but finally what is the value of all those DSLs, apart from letting you put the SELECT clause at the end?
I wrote more of my thoughts on the subject here: https://noteflakes.com/articles/2026-09-05-beyond-orms
That’s what I’ve done for the past few years. I don’t see the point in writing SQL keywords as methods. The only thing that’s tricky is when you need dynamic queries (eg. appending a WHERE filter if a variable is present) but I just write them as static queries instead (WHERE $1 IS NULL OR foo = $1). Writing raw SQL has made me wayyy better at SQL, I can easily copy paste queries if I want to try them in a DB query tool, and I don’t have surprise regarding the final query since it’s already written.
Which begs the question, why use them in the first place?
Using a DSL instead of raw SQL brings several benefits:
WHERE admin = true clause, without having to concatenate strings or duplicate queries entirely (one for regular users, one for admins, etc)Note that there's a difference between a query builder and an ORM, and the above specifically refers to query builders. ORMs can be useful but depending on their implementation they tend to add a lot of bloat that may not be necessary, and they typically force you into a very particular way of doing things.
I think this is the correct take. It's easy to mix up query builders and ORMs.
WRT. to the grandparent post, there's no reason a query builder cannot support all SQL features in a given database. ORMs are limited because they need to return well-defined objects, although many blur the lines wrt. to query builders and may allow using SQL that doesn't map to objects.
I don't think query builders can do such useful portability across different databases, at least for the kind of applications I find interesting. And I don't feel bad about SQL libraries I have used not preventing SQL injection enough. But I do agree on the other points.
It would be great if some SQL database offered an alternative UI where you can feed queries as ASTs or structured data. I was mulling writing a lower-level-than-SQL database on top of LMDB with real-time functionality and I was designing the query language as structured data rather than text. By not using text as the interface, writing alternative query languages would work better.
Presumably one could do things like join generation helpers (based on the model one already has somewhere in the code), abstract frequently-used fragments of where, etc.? At least that's what I built for the project on top of clsql.
The biggest benefit I'd say for query builders beyond the syntax changes (which might be a wash, depending on one's preferences) is that they allow you to abstract common logic out in a way that almost always ends up awkward, especially from a performance angle, in a number of SQL dialects. I once tried to factor things out into views and procs for a big query I'd come across in a T-SQL environment, doing so cut the performance by a -lot-
Perhaps query optimizers have made strides in that department, I've not been slinging SQL in a while, but that's where I see the benefits.
I think that the DSLs that simply map to the existing SQL structure (like ActiveRecord) are less useful than ones that create a pipeline abstraction (like FunSQL). IMO the latter is much easier to compose programmatically or build extra helper functions, as each step is just a transform from one schema to another.
There are advantages of having your "main" or "real" language be aware of the queries so you can do cross-references, etc. Doing less string concatenation to build SQL also discourages people from accidentally/unknowningly creating an SQL injection vulnerability.
Sure, SQL has some warts, but finally what is the value of all those DSLs, apart from letting you put the SELECT clause at the end?
Indeed. And isn’t this prioritising writability (and code completion) over readability? I don’t want to have to read the entire query to figure out what it actually returns. (Although, what I really want is for people to use views more, in which case it wouldn’t matter as much).
why use them in the first place?
Two reasons for me. One is that you can have a pipe syntax for generic keywords that just happen to translate to subqueries, ctes, joins, and other constructs as needed. Or filters that turn into where/having as needed. Or groups that can create all the weird subqueries and syntax weirdness automatically. I mean, check for example the "orthogonality" and "top N by group" examples on https://prql-lang.org/
The other is that pipe-style queries are composable - you can insert a subexpression or append to the end, knowing you don't have to suddenly rearchitect the whole thing due to SQL limitations.
I'm not entirely convinced any of those query builders do anything you couldn't express in a cleaner way with SQL. Generally speaking, I'd say the Django style of query building is the best one out there in that it actually feels like it takes advantage of using a programming language to express your SQL queries; to express the same sample query in Django, it'd be something like this:
Person.objects.filter(gender_concept_id=8507).order_by('year_of_birth').values_list('person_id', flat=True)[:100]
The main advantages I find this has are that filter is just expressed with keyword arguments (making it really easy to create dynamic queries without needing to violate DRY in annoying ways) rather than a messy OR/AND system to express the where clause, that the select equivalent (values_list) is technically optional (it's usually more of a specific optimization to use it, as you just get a model without it) and that to limit the list, you can use python slices rather than more function stacking.
Not to say Django is perfect though; there's definitely some warts on it's ORM. Double underscores for processing filter components are an acquired taste for example. Similarly, annotating queries that are more complex than Sum/Count/Min/Max quickly becomes a headache. But at that point you might as well drop down to SQL anyways, as every query builder and ORM I've seen just absolutely shits the bed on clean code for that subject. Same with tricks like CTEs. For almost every CRUD operation (which are tbf most operations on a database), it works fine though.
Oh one final pain points about the Django ORM is how stacking filter/exclude clauses work vs. adding keyword arguments to a single call. Stacking filter clauses works more or less the way you expect; every filter clause filters the query down further, and usually the difference between keyword arguments and more function calls is meaningless. For exclude calls, combining keyword arguments instead creates an AND clause on every exclude keyword argument, so usually you end up needing to stack the function calls. It's a bit weird but you get used to it.
If I could change one thing about the Django ORM design it would be the double underscore thing, mainly because it's incompatible with type hints (which were more than a decade from becoming a thing when the Django ORM was designed.)