Skip to content

Commit 7f18724

Browse files
committed
Add Dart API to build compound statements
Closes #3346
1 parent fdfaff9 commit 7f18724

File tree

10 files changed

+396
-28
lines changed

10 files changed

+396
-28
lines changed

docs/docs/dart_api/select.md

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ description: Select rows or individual columns from tables in Dart
55

66
---
77

8-
9-
10-
118
This page describes how to write `SELECT` statements with drift's dart_api.
129
To make examples easier to grasp, they're referencing two common tables forming
1310
the basis of a todo-list app:
@@ -288,8 +285,6 @@ Any statement can be used as a subquery. But be aware that, unlike [subquery exp
288285

289286
## JSON support
290287

291-
292-
293288
sqlite3 has great support for [JSON operators](https://sqlite.org/json1.html) that are also available
294289
in drift (under the additional `'package:drift/extensions/json1.dart'` import).
295290
JSON support is helpful when storing a dynamic structure that is best represented with JSON, or when
@@ -333,3 +328,34 @@ at all.
333328
Instead, the expressions in the list passed to `selectExpressions` are evaluated in a standalone
334329
select statement and can be parsed from the `TypedResult` class returned when evaluating the
335330
query.
331+
332+
## Compound selects
333+
334+
With compound selects, the results of multiple selects statements can be returned at once.
335+
Different operators are available to apply set operations on queries, namely:
336+
337+
1. `UNION ALL` and `UNION`: Returns the results of two select statements in a select, with
338+
duplicates included or filtered, respectively.
339+
2. `EXCEPT`: Returns all rows of the first select statement that did not appear in the second
340+
query.
341+
3. `INTERSECT`: Returns all rows that were returned by both select statements.
342+
343+
As an example, consider the tables used to track todo items introduced [in the article on tables](tables.md#defining-tables). Here, one table stores todo items and another table defines categories
344+
that can be used to group these items.
345+
Now, perhaps you want to query how many items are assigned to each category, as well as the amount
346+
of items not in any category.
347+
The first query can be written with a `groupBy` on categories and a [subquery](expressions.md#subqueries)
348+
to count associated todo items.
349+
When grouping on the categories table though, there will be no "null" group. So, one way to resolve
350+
everything in a single query is to write another query and use `unionAll`:
351+
352+
{{ load_snippet('compound','lib/snippets/dart_api/select.dart.excerpt.json') }}
353+
354+
This query will return one row for each category, counting associated todo items. Also, it includes
355+
a final row without a category description reporting the count of todo items outside of categories.
356+
357+
With all of these operators, all involved queries must return compatible rows. This is because
358+
the queries are ultimately reported as a single result set, so they must return the same column
359+
types.
360+
It is possible to apply a `LIMIT` and `ORDER BY` clause to compound select statements, but only
361+
to the first statement (the one on which `union`, `unionAll`, `except` or `intersect` is called).

docs/lib/snippets/dart_api/select.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,4 +211,26 @@ extension SelectExamples on CanUseCommonTables {
211211
return row.read(todoItemExists)!;
212212
}
213213
// #enddocregion hasTodoItem
214+
215+
// #docregion compound
216+
Future<List<(String?, int)>> todoItemsInCategory() async {
217+
final countWithCategory = subqueryExpression<int>(selectOnly(todoItems)
218+
..addColumns([countAll()])
219+
..where(todoItems.category.equalsExp(categories.id)));
220+
221+
final countWithoutCategory = subqueryExpression<int>(selectOnly(todoItems)
222+
..addColumns([countAll()])
223+
..where(todoItems.category.isNull()));
224+
225+
final query = db.selectOnly(categories)
226+
..addColumns([categories.name, countWithoutCategory])
227+
..groupBy([categories.id]);
228+
query.unionAll(db.selectExpressions(
229+
[const Constant<String>(null), countWithoutCategory]));
230+
231+
return query
232+
.map((row) => (row.read(categories.name), row.read(countWithCategory)!))
233+
.get();
234+
}
235+
// #enddocregion compound
214236
}

drift/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 2.23.0-dev
2+
3+
- Allow building compound select statements in Dart.
4+
15
## 2.22.1
26

37
- Fix generated SQL for `insertFromSelect` statements with upserts.

drift/lib/src/runtime/api/connection_user.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,8 @@ abstract class DatabaseConnectionUser {
268268
/// final row = await selectExpressions([currentDateAndTime]).getSingle();
269269
/// final databaseTime = row.read(currentDateAndTime)!;
270270
/// ```
271-
Selectable<TypedResult> selectExpressions(Iterable<Expression> columns) {
271+
BaseSelectStatement<TypedResult> selectExpressions(
272+
Iterable<Expression> columns) {
272273
return SelectWithoutTables(this, columns);
273274
}
274275

drift/lib/src/runtime/query_builder/statements/query.dart

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,14 @@ abstract class Query<T extends HasResultSet, D> extends Component {
3737
@visibleForOverriding
3838
void writeStartPart(GenerationContext ctx);
3939

40-
@override
41-
void writeInto(GenerationContext context) {
40+
void _writeInto(GenerationContext context,
41+
{bool withOrderByAndLimit = true}) {
4242
// whether we need to insert a space before writing the next component
4343
var needsWhitespace = false;
4444

45-
void writeWithSpace(Component? component) {
45+
void writeWithSpace(
46+
Component? component,
47+
) {
4648
if (component == null) return;
4749

4850
if (needsWhitespace) context.writeWhitespace();
@@ -55,8 +57,10 @@ abstract class Query<T extends HasResultSet, D> extends Component {
5557

5658
writeWithSpace(whereExpr);
5759
writeWithSpace(_groupBy);
58-
writeWithSpace(orderByExpr);
59-
writeWithSpace(limitExpr);
60+
if (withOrderByAndLimit) {
61+
writeWithSpace(orderByExpr);
62+
writeWithSpace(limitExpr);
63+
}
6064

6165
if (writeReturningClause) {
6266
if (needsWhitespace) context.writeWhitespace();
@@ -65,6 +69,11 @@ abstract class Query<T extends HasResultSet, D> extends Component {
6569
}
6670
}
6771

72+
@override
73+
void writeInto(GenerationContext context) {
74+
_writeInto(context);
75+
}
76+
6877
/// Constructs the query that can then be sent to the database executor.
6978
///
7079
/// This is used internally by drift to run the query. Users should use the

drift/lib/src/runtime/query_builder/statements/select/select.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ typedef OrderClauseGenerator<T> = OrderingTerm Function(T tbl);
88
///
99
/// Users are not allowed to extend, implement or mix-in this class.
1010
@sealed
11-
abstract class BaseSelectStatement<Row> extends Component {
11+
abstract class BaseSelectStatement<Row> extends Component with Selectable<Row> {
1212
Iterable<(Expression, String)> get _expandedColumns;
1313

1414
/// The name for the given [expression] in the result set, or `null` if
@@ -192,6 +192,7 @@ final class SelectWithoutTables extends BaseSelectStatement<TypedResult>
192192

193193
@override
194194
void writeInto(GenerationContext context) {
195+
final isRoot = context.buffer.isEmpty;
195196
context.buffer.write('SELECT ');
196197
var first = true;
197198
for (final MapEntry(key: expr, value: alias) in _columns.entries) {
@@ -202,7 +203,8 @@ final class SelectWithoutTables extends BaseSelectStatement<TypedResult>
202203
expr.writeInto(context);
203204
context.buffer.write(' ${context.identifier(alias)}');
204205
}
205-
context.buffer.write(';');
206+
207+
if (isRoot) context.buffer.write(';');
206208
}
207209

208210
GenerationContext _createContext() {

drift/lib/src/runtime/query_builder/statements/select/select_with_join.dart

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ part of '../../query_builder.dart';
77
class JoinedSelectStatement<FirstT extends HasResultSet, FirstD>
88
extends Query<FirstT, FirstD>
99
with LimitContainerMixin, Selectable<TypedResult>
10-
implements BaseSelectStatement {
10+
implements BaseSelectStatement<TypedResult> {
1111
/// Used internally by drift, users should use [SimpleSelectStatement.join]
1212
/// instead.
1313
JoinedSelectStatement(super.database, super.table, this._joins,
@@ -36,6 +36,10 @@ class JoinedSelectStatement<FirstT extends HasResultSet, FirstD>
3636
/// here. They're just named in increasing order, so something like `AS c3`.
3737
final Map<Expression, String> _columnAliases = {};
3838

39+
/// Compound statements that have been added to this select statements, e.g.
40+
/// through
41+
final List<(_CompoundOperator, BaseSelectStatement)> _compounds = [];
42+
3943
/// The tables this select statement reads from
4044
@visibleForOverriding
4145
@Deprecated('Use watchedTables on the generated context')
@@ -115,6 +119,149 @@ class JoinedSelectStatement<FirstT extends HasResultSet, FirstD>
115119
}
116120
}
117121

122+
void _addCompound(_CompoundOperator operator, BaseSelectStatement other) {
123+
if (other is JoinedSelectStatement) {
124+
if (other.limitExpr != null ||
125+
other.orderByExpr != null ||
126+
other._compounds.isNotEmpty) {
127+
throw ArgumentError(
128+
"Can't add compound query that has a limit or an order-by clause. "
129+
'Also, the added query must hot have its own compound parts. Add '
130+
'the clauses and parts to the top-level parts instead.');
131+
}
132+
}
133+
134+
var columnsHere = _expandedColumns.iterator;
135+
var otherColumns = other._expandedColumns.iterator;
136+
var columnCount = 0;
137+
138+
while (columnsHere.moveNext()) {
139+
if (!otherColumns.moveNext()) {
140+
throw ArgumentError(
141+
"Can't add select with fewer columns (added part has "
142+
'$columnCount columns, the original source has more).');
143+
}
144+
145+
var here = columnsHere.current;
146+
var otherColumn = otherColumns.current;
147+
148+
if (here.$1.driftSqlType != otherColumn.$1.driftSqlType) {
149+
throw ArgumentError(
150+
"Can't add part because the column types at index $columnCount "
151+
'differ.');
152+
}
153+
154+
columnCount++;
155+
}
156+
157+
if (otherColumns.moveNext()) {
158+
throw ArgumentError(
159+
"Can't add select with more columns (the original query has "
160+
'$columnCount columns, the added part has more).');
161+
}
162+
163+
_compounds.add((operator, other));
164+
}
165+
166+
/// Appends the [other] statement as a `UNION` clause after this query.
167+
///
168+
/// The database will run both queries and return all rows involved in either
169+
/// query, removing duplicates. For this to work, this and [other] must have
170+
/// compatible columns.
171+
///
172+
/// The [other] query must not include a `LIMIT` or a `ORDER BY` clause.
173+
/// Compound statements can only contain a single `LIMIT` and `ORDER BY`
174+
/// clause at the end, which is set on the first statement (on which
175+
/// [union] is called). Also, the [other] statement must not contain compound
176+
/// parts on its own.
177+
///
178+
/// As an example, consider a `todos` table of todo items referencing a
179+
/// `categories` table used to group them. With that structure, it's possible
180+
/// to compute the amount of todo items in each category, as well as the
181+
/// amount of todo items not in a category in a single query:
182+
///
183+
/// ```dart
184+
/// final count = subqueryExpression<int>(selectOnly(todos)
185+
/// ..addColumns([countAll()])
186+
/// ..where(todos.category.equalsExp(categories.id)));
187+
/// final countWithoutCategory = subqueryExpression<int>(db.selectOnly(todos)
188+
/// ..addColumns([countAll()])
189+
/// ..where(todos.category.isNull()));
190+
///
191+
/// final query = db.selectOnly(db.categories)
192+
/// ..addColumns([db.categories.description, count])
193+
/// ..groupBy([categories.id]);
194+
/// query.union(db.selectExpressions(
195+
/// [const Constant<String>(null), countWithoutCategory]));
196+
/// ```
197+
void union(BaseSelectStatement other) {
198+
_addCompound(_CompoundOperator.union, other);
199+
}
200+
201+
/// Appends the [other] statement as a `UNION ALL` clause after this query.
202+
///
203+
/// The database will run both queries and return all rows involved in either
204+
/// query. For this to work, this and [other] must have compatible columns.
205+
///
206+
/// The [other] query must not include a `LIMIT` or a `ORDER BY` clause.
207+
/// Compound statements can only contain a single `LIMIT` and `ORDER BY`
208+
/// clause at the end, which is set on the first statement (on which
209+
/// [unionAll] is called). Also, the [other] statement must not contain
210+
/// compound parts on its own.
211+
///
212+
/// As an example, consider a `todos` table of todo items referencing a
213+
/// `categories` table used to group them. With that structure, it's possible
214+
/// to compute the amount of todo items in each category, as well as the
215+
/// amount of todo items not in a category in a single query:
216+
///
217+
/// ```dart
218+
/// final count = subqueryExpression<int>(selectOnly(todos)
219+
/// ..addColumns([countAll()])
220+
/// ..where(todos.category.equalsExp(categories.id)));
221+
/// final countWithoutCategory = subqueryExpression<int>(db.selectOnly(todos)
222+
/// ..addColumns([countAll()])
223+
/// ..where(todos.category.isNull()));
224+
///
225+
/// final query = db.selectOnly(db.categories)
226+
/// ..addColumns([db.categories.description, count])
227+
/// ..groupBy([categories.id]);
228+
/// query.unionAll(db.selectExpressions(
229+
/// [const Constant<String>(null), countWithoutCategory]));
230+
/// ```
231+
void unionAll(BaseSelectStatement other) {
232+
_addCompound(_CompoundOperator.unionAll, other);
233+
}
234+
235+
/// Appends the [other] statement as a `EXCEPT` clause after this query.
236+
///
237+
/// The database will run both queries and return all rows of the first query
238+
/// that were not returned by [other]. For this to work, this and [other] must
239+
/// have compatible columns.
240+
///
241+
/// The [other] query must not include a `LIMIT` or a `ORDER BY` clause.
242+
/// Compound statements can only contain a single `LIMIT` and `ORDER BY`
243+
/// clause at the end, which is set on the first statement (on which
244+
/// [except] is called). Also, the [other] statement must not contain
245+
/// compound parts on its own.
246+
void except(BaseSelectStatement other) {
247+
_addCompound(_CompoundOperator.except, other);
248+
}
249+
250+
/// Appends the [other] statement as a `INTERSECT` clause after this query.
251+
///
252+
/// The database will run both queries and return all rows that were returned
253+
/// by both queries. For this to work, this and [other] must have compatible
254+
/// columns.
255+
///
256+
/// The [other] query must not include a `LIMIT` or a `ORDER BY` clause.
257+
/// Compound statements can only contain a single `LIMIT` and `ORDER BY`
258+
/// clause at the end, which is set on the first statement (on which
259+
/// [intersect] is called). Also, the [other] statement must not contain
260+
/// compound parts on its own.
261+
void intersect(BaseSelectStatement other) {
262+
_addCompound(_CompoundOperator.intersect, other);
263+
}
264+
118265
@override
119266
void writeStartPart(GenerationContext ctx) {
120267
ctx.hasMultipleTables = true;
@@ -150,6 +297,28 @@ class JoinedSelectStatement<FirstT extends HasResultSet, FirstD>
150297
}
151298
}
152299

300+
@override
301+
void writeInto(GenerationContext context) {
302+
if (_compounds.isEmpty) {
303+
super.writeInto(context);
304+
} else {
305+
// The order by and limit clauses must appear after the compounds.
306+
super._writeInto(context, withOrderByAndLimit: false);
307+
308+
for (final (operator, statement) in _compounds) {
309+
context.writeWhitespace();
310+
context.buffer.write(operator.lexeme);
311+
context.writeWhitespace();
312+
statement.writeInto(context);
313+
}
314+
315+
context.writeWhitespace();
316+
orderByExpr?.writeInto(context);
317+
context.writeWhitespace();
318+
limitExpr?.writeInto(context);
319+
}
320+
}
321+
153322
/// Applies the [predicate] as the where clause, which will be used to filter
154323
/// results.
155324
///
@@ -401,3 +570,14 @@ extension JoinedSelectStatementAdditionalTables on JoinedSelectStatement {
401570
const []]) =>
402571
_watchWithAdditionalTables(tables);
403572
}
573+
574+
enum _CompoundOperator {
575+
union('UNION'),
576+
unionAll('UNION ALL'),
577+
intersect('INTERSECT'),
578+
except('EXCEPT');
579+
580+
final String lexeme;
581+
582+
const _CompoundOperator(this.lexeme);
583+
}

0 commit comments

Comments
 (0)