@stamhoofd/sql 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@stamhoofd/sql",
3
+ "version": "2.1.1",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "license": "UNLICENCED",
7
+ "sideEffects": false,
8
+ "files": [
9
+ "src"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc -b",
13
+ "build:full": "rm -rf ./dist && yarn build"
14
+ }
15
+ }
package/src/SQL.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { SQLExpression } from "./SQLExpression";
2
+ import { SQLSelect } from "./SQLSelect";
3
+ import { SQLColumnExpression, SQLSafeValue, SQLTableExpression, SQLWildcardSelectExpression, scalarToSQLExpression } from "./SQLExpressions";
4
+ import { SQLJoin, SQLJoinType } from "./SQLJoin";
5
+ import { SQLJsonExtract } from "./SQLJsonExpressions";
6
+
7
+ class StaticSQL {
8
+ wildcard(namespace?: string) {
9
+ return new SQLWildcardSelectExpression(namespace)
10
+ }
11
+
12
+ column(namespace: string, column: string): SQLColumnExpression;
13
+ column(column: string): SQLColumnExpression;
14
+ column(namespaceOrColumn: string, column?: string): SQLColumnExpression {
15
+ if (column === undefined) {
16
+ return new SQLColumnExpression(namespaceOrColumn)
17
+ }
18
+ return new SQLColumnExpression(namespaceOrColumn, column)
19
+ }
20
+
21
+ jsonValue(column: SQLExpression, path: string): SQLJsonExtract {
22
+ return new SQLJsonExtract(column, new SQLSafeValue(path))
23
+ }
24
+
25
+ table(namespace: string, table: string): SQLTableExpression;
26
+ table(table: string): SQLTableExpression;
27
+ table(namespaceOrTable: string, table?: string): SQLTableExpression {
28
+ if (table === undefined) {
29
+ return new SQLTableExpression(namespaceOrTable)
30
+ }
31
+ return new SQLTableExpression(namespaceOrTable, table)
32
+ }
33
+
34
+ select(...columns: SQLExpression[]): InstanceType<typeof SQLSelect> {
35
+ if (columns.length === 0) {
36
+ return new SQLSelect(this.wildcard())
37
+ }
38
+ return new SQLSelect(...columns)
39
+ }
40
+
41
+ leftJoin(table: SQLExpression) {
42
+ return new SQLJoin(SQLJoinType.Left, table)
43
+ }
44
+
45
+ rightJoin(table: SQLExpression) {
46
+ return new SQLJoin(SQLJoinType.Right, table)
47
+ }
48
+
49
+ innerJoin(table: SQLExpression) {
50
+ return new SQLJoin(SQLJoinType.Inner, table)
51
+ }
52
+
53
+ join(table: SQLExpression) {
54
+ return new SQLJoin(SQLJoinType.Inner, table)
55
+ }
56
+ }
57
+
58
+ export const SQL = new StaticSQL();
@@ -0,0 +1,30 @@
1
+ export type SQLExpressionOptions = {
2
+ defaultNamespace?: string
3
+ };
4
+
5
+ export type NormalizedSQLQuery = {query: string; params: any[]}
6
+ export type SQLQuery = NormalizedSQLQuery|string
7
+
8
+ export function joinSQLQuery(queries: (SQLQuery|undefined|null)[], seperator?: string): NormalizedSQLQuery {
9
+ queries = queries.filter(q => q !== undefined && q !== null)
10
+ return {
11
+ query: queries.map(q => typeof q === 'string' ? q : q!.query).join(seperator ?? ''),
12
+ params: queries.flatMap(q => typeof q === 'string' ? [] : q!.params)
13
+ }
14
+ }
15
+
16
+ export function normalizeSQLQuery(q: SQLQuery): NormalizedSQLQuery {
17
+ return {
18
+ query: typeof q === 'string' ? q : q.query,
19
+ params: typeof q === 'string' ? [] : q.params
20
+ }
21
+ }
22
+
23
+
24
+ export interface SQLExpression {
25
+ getSQL(options?: SQLExpressionOptions): SQLQuery
26
+ }
27
+
28
+ export function isSQLExpression(obj: unknown): obj is SQLExpression {
29
+ return typeof obj === 'object' && obj !== null && !!(obj as any).getSQL && typeof (obj as any).getSQL === 'function'
30
+ }
@@ -0,0 +1,246 @@
1
+ import { isSQLExpression, joinSQLQuery, SQLExpression, SQLExpressionOptions, SQLQuery } from "./SQLExpression";
2
+ import {Database} from "@simonbackx/simple-database"
3
+
4
+ export type SQLScalarValue = string|number|boolean|Date;
5
+ export type SQLDynamicExpression = SQLScalarValue|SQLScalarValue[]|null|SQLExpression
6
+
7
+ export function scalarToSQLJSONExpression(s: SQLScalarValue|null): SQLExpression {
8
+ if (s === null) {
9
+ return new SQLJSONValue(null)
10
+ }
11
+
12
+ if (s === true) {
13
+ return new SQLJSONValue(true)
14
+ }
15
+
16
+ if (s === false) {
17
+ return new SQLJSONValue(false)
18
+ }
19
+
20
+ return new SQLScalar(s)
21
+ }
22
+
23
+ export function scalarToSQLExpression(s: SQLScalarValue|null): SQLExpression {
24
+ if (s === null) {
25
+ return new SQLNull()
26
+ }
27
+
28
+ return new SQLScalar(s)
29
+ }
30
+
31
+ export function readDynamicSQLExpression(s: SQLDynamicExpression): SQLExpression {
32
+ if (Array.isArray(s)) {
33
+ return new SQLArray(s)
34
+ }
35
+ if (s === null) {
36
+ return new SQLNull()
37
+ }
38
+
39
+ if (typeof s === 'object' && !(s instanceof Date)) {
40
+ return s;
41
+ }
42
+
43
+ return new SQLScalar(s)
44
+ }
45
+
46
+ export class SQLCount implements SQLExpression {
47
+ expression: SQLExpression|null
48
+
49
+ constructor(expression: SQLExpression|null = null) {
50
+ this.expression = expression
51
+ }
52
+
53
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
54
+ return joinSQLQuery([
55
+ 'COUNT(',
56
+ this.expression ? this.expression.getSQL(options) : '*',
57
+ ')'
58
+ ])
59
+ }
60
+ }
61
+
62
+ export class SQLSelectAs implements SQLExpression {
63
+ expression: SQLExpression
64
+ as: SQLAlias
65
+
66
+ constructor(expression: SQLExpression, as: SQLAlias) {
67
+ this.expression = expression
68
+ this.as = as;
69
+ }
70
+
71
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
72
+ return joinSQLQuery([
73
+ this.expression.getSQL(options),
74
+ ' AS ',
75
+ this.as.getSQL(options)
76
+ ])
77
+ }
78
+ }
79
+
80
+ export class SQLAlias implements SQLExpression {
81
+ name: string;
82
+
83
+ constructor(name: string) {
84
+ this.name = name
85
+ }
86
+
87
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
88
+ return Database.escapeId(this.name) ;
89
+ }
90
+ }
91
+
92
+
93
+ export class SQLConcat implements SQLExpression {
94
+ expressions: SQLExpression[];
95
+
96
+ constructor(...expressions: SQLExpression[]) {
97
+ this.expressions = expressions
98
+ }
99
+
100
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
101
+ return joinSQLQuery([
102
+ 'CONCAT(',
103
+ joinSQLQuery(this.expressions.map(e => e.getSQL(options)), ', '),
104
+ ')'
105
+ ])
106
+ }
107
+ }
108
+
109
+
110
+ export class SQLAge implements SQLExpression {
111
+ expression: SQLExpression;
112
+
113
+ constructor(expression: SQLExpression) {
114
+ this.expression = expression
115
+ }
116
+
117
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
118
+ return joinSQLQuery([
119
+ 'TIMESTAMPDIFF(YEAR, ',
120
+ this.expression.getSQL(options),
121
+ ', CURDATE())'
122
+ ])
123
+ }
124
+ }
125
+
126
+ export class SQLJSONValue implements SQLExpression {
127
+ value: null|true|false;
128
+
129
+ constructor(value: null|true|false) {
130
+ this.value = value;
131
+ }
132
+
133
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
134
+ return "CAST('"+JSON.stringify(this.value)+"' AS JSON)";
135
+ }
136
+ }
137
+
138
+ export class SQLNull implements SQLExpression {
139
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
140
+ return 'NULL';
141
+ }
142
+ }
143
+
144
+ export class SQLNow implements SQLExpression {
145
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
146
+ return 'NOW()';
147
+ }
148
+ }
149
+
150
+ export class SQLScalar implements SQLExpression {
151
+ value: SQLScalarValue;
152
+
153
+ constructor(value: SQLScalarValue) {
154
+ this.value = value
155
+ }
156
+
157
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
158
+ return {
159
+ query: '?',
160
+ params: [this.value]
161
+ }
162
+ }
163
+ }
164
+
165
+ export class SQLSafeValue implements SQLExpression {
166
+ value: string|number;
167
+
168
+ constructor(value: string|number) {
169
+ this.value = value
170
+ }
171
+
172
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
173
+ return JSON.stringify(this.value);
174
+ }
175
+ }
176
+
177
+
178
+ export class SQLArray implements SQLExpression {
179
+ value: SQLScalarValue[];
180
+
181
+ constructor(value: SQLScalarValue[]) {
182
+ this.value = value
183
+ }
184
+
185
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
186
+ return {
187
+ query: '(?)',
188
+ params: [this.value]
189
+ }
190
+ }
191
+ }
192
+
193
+ export class SQLWildcardSelectExpression implements SQLExpression {
194
+ namespace?: string;
195
+
196
+ constructor(namespace?: string) {
197
+ this.namespace = namespace
198
+ }
199
+
200
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
201
+ return Database.escapeId(this.namespace ?? options?.defaultNamespace ?? '') + '.*'
202
+ }
203
+ }
204
+
205
+ export class SQLColumnExpression implements SQLExpression {
206
+ namespace?: string;
207
+ column: string;
208
+
209
+ constructor(namespace: string, column: string);
210
+ constructor(column: string);
211
+ constructor(namespaceOrColumn: string, column?: string) {
212
+ if (column === undefined) {
213
+ this.column = namespaceOrColumn;
214
+ return;
215
+ }
216
+ this.namespace = namespaceOrColumn
217
+ this.column = column
218
+ }
219
+
220
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
221
+ return Database.escapeId(this.namespace ?? options?.defaultNamespace ?? '') + '.' + Database.escapeId(this.column)
222
+ }
223
+ }
224
+
225
+ export class SQLTableExpression implements SQLExpression {
226
+ namespace?: string;
227
+ table: string;
228
+
229
+ constructor(namespace: string, table: string);
230
+ constructor(table: string);
231
+ constructor(namespaceOrTable: string, table?: string) {
232
+ if (table === undefined) {
233
+ this.table = namespaceOrTable;
234
+ return;
235
+ }
236
+ this.namespace = namespaceOrTable
237
+ this.table = table
238
+ }
239
+
240
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
241
+ if (!this.namespace) {
242
+ return Database.escapeId(this.table)
243
+ }
244
+ return Database.escapeId(this.table) + ' ' + Database.escapeId(this.namespace)
245
+ }
246
+ }
package/src/SQLJoin.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { SQLExpression, SQLExpressionOptions, SQLQuery, joinSQLQuery } from "./SQLExpression";
2
+ import { SQLWhere, addWhereHelpers } from "./SQLWhere";
3
+
4
+ export enum SQLJoinType {
5
+ Left = "Left",
6
+ Right = "Right",
7
+ Inner = "Inner",
8
+ Outer = "Outer"
9
+ }
10
+
11
+ export class JoinBase implements SQLExpression {
12
+ type = SQLJoinType.Left
13
+ table: SQLExpression;
14
+ _where: SQLWhere|null = null;
15
+
16
+ constructor(type: SQLJoinType, table: SQLExpression) {
17
+ this.type = type;
18
+ this.table = table;
19
+ }
20
+
21
+ private getJoinPrefix(): string {
22
+ switch (this.type) {
23
+ case SQLJoinType.Left: return 'LEFT JOIN';
24
+ case SQLJoinType.Right: return 'RIGHT JOIN';
25
+ case SQLJoinType.Inner: return 'JOIN';
26
+ case SQLJoinType.Outer: return 'OUTER JOIN';
27
+ }
28
+ }
29
+
30
+ getSQL(options?: SQLExpressionOptions | undefined): SQLQuery {
31
+ return joinSQLQuery([
32
+ this.getJoinPrefix(),
33
+ this.table?.getSQL(),
34
+ this._where ? 'ON' : undefined,
35
+ this._where?.getSQL()
36
+ ], ' ')
37
+ }
38
+ }
39
+
40
+ export const SQLJoin = addWhereHelpers(JoinBase)
@@ -0,0 +1,111 @@
1
+ import { SQLExpression, SQLExpressionOptions, SQLQuery, joinSQLQuery } from "./SQLExpression";
2
+ import { SQLSafeValue } from "./SQLExpressions";
3
+
4
+ /**
5
+ * Same as target->path, JSON_EXTRACT(target, path)
6
+ */
7
+ export class SQLJsonExtract implements SQLExpression {
8
+ target: SQLExpression
9
+ path: SQLExpression
10
+
11
+ constructor(target: SQLExpression, path: SQLExpression) {
12
+ this.target = target;
13
+ this.path = path;
14
+ }
15
+
16
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
17
+ return joinSQLQuery([
18
+ 'JSON_EXTRACT(',
19
+ this.target.getSQL(options),
20
+ ',',
21
+ this.path.getSQL(options),
22
+ ')'
23
+ ])
24
+ }
25
+ }
26
+
27
+ /**
28
+ * JSON_SEARCH(json_doc, one_or_all, search_str[, escape_char[, path] ...])
29
+ */
30
+ export class SQLJsonSearch implements SQLExpression {
31
+ target: SQLExpression
32
+ oneOrAll: 'one'|'all'
33
+ searchStr: SQLExpression;
34
+ path: SQLExpression|null;
35
+
36
+ constructor(target: SQLExpression, oneOrAll: 'one'|'all', searchStr: SQLExpression, path: SQLExpression|null = null) {
37
+ this.target = target;
38
+ this.oneOrAll = oneOrAll;
39
+ this.searchStr = searchStr;
40
+ this.path = path;
41
+ }
42
+
43
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
44
+ return joinSQLQuery([
45
+ 'JSON_SEARCH(',
46
+ this.target.getSQL(options),
47
+ ',',
48
+ new SQLSafeValue(this.oneOrAll).getSQL(options),
49
+ ',',
50
+ this.searchStr.getSQL(options),
51
+ ...(this.path ? [
52
+ ',',
53
+ this.path.getSQL(options)
54
+ ] : []),
55
+ ')'
56
+ ])
57
+ }
58
+ }
59
+
60
+ /**
61
+ * JSON_CONTAINS(target, candidate[, path])
62
+ */
63
+ export class SQLJsonContains implements SQLExpression {
64
+ target: SQLExpression
65
+ candidate: SQLExpression;
66
+ path: SQLExpression|null;
67
+
68
+ constructor(target: SQLExpression, candidate: SQLExpression, path: SQLExpression|null = null) {
69
+ this.target = target;
70
+ this.candidate = candidate;
71
+ this.path = path;
72
+ }
73
+
74
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
75
+ return joinSQLQuery([
76
+ 'JSON_CONTAINS(',
77
+ this.target.getSQL(options),
78
+ ',',
79
+ this.candidate.getSQL(options),
80
+ ...(this.path ? [
81
+ ',',
82
+ this.path.getSQL(options)
83
+ ] : []),
84
+ ')'
85
+ ])
86
+ }
87
+ }
88
+
89
+
90
+ /**
91
+ * JSON_CONTAINS(json_doc1, json_doc2)
92
+ */
93
+ export class SQLJsonOverlaps implements SQLExpression {
94
+ jsonDoc1: SQLExpression
95
+ jsonDoc2: SQLExpression;
96
+
97
+ constructor(jsonDoc1: SQLExpression, jsonDoc2: SQLExpression) {
98
+ this.jsonDoc1 = jsonDoc1;
99
+ this.jsonDoc2 = jsonDoc2;
100
+ }
101
+
102
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
103
+ return joinSQLQuery([
104
+ 'JSON_OVERLAPS(',
105
+ this.jsonDoc1.getSQL(options),
106
+ ',',
107
+ this.jsonDoc2.getSQL(options),
108
+ ')'
109
+ ])
110
+ }
111
+ }
@@ -0,0 +1,63 @@
1
+ import { SQLExpression, SQLExpressionOptions, SQLQuery, joinSQLQuery } from "./SQLExpression";
2
+
3
+ type GConstructor<T = {}> = new (...args: any[]) => T;
4
+ type Orderable = GConstructor<{ _orderBy: SQLOrderBy|null }>;
5
+
6
+ export function addOrderByHelpers<TBase extends Orderable>(Base: TBase) {
7
+ return class extends Base {
8
+
9
+ orderBy(orderBy: SQLOrderBy)
10
+ orderBy(column: SQLExpression, direction?: SQLOrderByDirection)
11
+ orderBy(columnOrOrderBy: SQLExpression, direction?: SQLOrderByDirection) {
12
+ let o = columnOrOrderBy as SQLOrderBy
13
+ if (!(columnOrOrderBy instanceof SQLOrderBy)) {
14
+ o = new SQLOrderBy({column: columnOrOrderBy, direction: direction ?? 'ASC'})
15
+ }
16
+
17
+ if (this._orderBy) {
18
+ this._orderBy.add(o)
19
+ } else {
20
+ this._orderBy = o;
21
+ }
22
+
23
+ return this;
24
+ }
25
+ }
26
+ }
27
+
28
+
29
+ export type SQLOrderByDirection = 'ASC' | 'DESC';
30
+ export class SQLOrderBy implements SQLExpression {
31
+ orderBy: {column: SQLExpression, direction: SQLOrderByDirection}[] = [];
32
+
33
+ constructor(...orderBy: {column: SQLExpression, direction: SQLOrderByDirection}[]) {
34
+ this.orderBy = orderBy
35
+ }
36
+
37
+ static combine(orderBy: SQLOrderBy[]) {
38
+ return new SQLOrderBy(...orderBy.flatMap(o => o.orderBy))
39
+ }
40
+
41
+ add(orderBy: SQLOrderBy) {
42
+ this.orderBy.push(...orderBy.orderBy)
43
+ }
44
+
45
+ getSQL(options?: SQLExpressionOptions | undefined): SQLQuery {
46
+ if (this.orderBy.length === 0) {
47
+ return '';
48
+ }
49
+
50
+ return joinSQLQuery([
51
+ 'ORDER BY ',
52
+ joinSQLQuery(
53
+ this.orderBy.map(o => {
54
+ return joinSQLQuery([
55
+ o.column.getSQL(options),
56
+ o.direction
57
+ ], ' ')
58
+ }),
59
+ ', '
60
+ )
61
+ ])
62
+ }
63
+ }
@@ -0,0 +1,160 @@
1
+ import { SQLExpression, SQLExpressionOptions, SQLQuery, joinSQLQuery, normalizeSQLQuery } from "./SQLExpression";
2
+ import { SQLOrderBy, addOrderByHelpers } from "./SQLOrderBy";
3
+ import { SQLWhere, addWhereHelpers } from "./SQLWhere";
4
+ import {Database, SQLResultNamespacedRow} from "@simonbackx/simple-database"
5
+ import {SQLJoin} from './SQLJoin'
6
+ import { SQLAlias, SQLCount, SQLSelectAs, SQLWildcardSelectExpression } from "./SQLExpressions";
7
+
8
+ class SelectBase implements SQLExpression {
9
+ _columns: SQLExpression[]
10
+ _from: SQLExpression;
11
+
12
+ _limit: number|null = null;
13
+ _offset: number|null = null;
14
+
15
+ _where: SQLWhere|null = null;
16
+ _orderBy: SQLOrderBy|null = null;
17
+ _joins: (InstanceType<typeof SQLJoin>)[] = [];
18
+
19
+ constructor(...columns: SQLExpression[]) {
20
+ this._columns = columns;
21
+ }
22
+
23
+ clone(): this {
24
+ const c = new SQLSelect(...this._columns)
25
+ Object.assign(c, this);
26
+ return c as any;
27
+ }
28
+
29
+ from(table: SQLExpression): this {
30
+ this._from = table;
31
+ return this;
32
+ }
33
+
34
+ join(join: InstanceType<typeof SQLJoin>): this {
35
+ this._joins.push(join);
36
+ return this;
37
+ }
38
+
39
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
40
+ const query: SQLQuery[] = [
41
+ 'SELECT'
42
+ ]
43
+
44
+ options = options ?? {}
45
+ options.defaultNamespace = (this._from as any).namespace ?? (this._from as any).table ?? undefined;
46
+
47
+ const columns = this._columns.map(c => c.getSQL(options))
48
+ query.push(
49
+ joinSQLQuery(columns, ', ')
50
+ )
51
+
52
+ query.push(
53
+ 'FROM'
54
+ )
55
+
56
+ query.push(this._from.getSQL(options));
57
+
58
+ query.push(...this._joins.map(j => j.getSQL(options)))
59
+
60
+ if (this._where) {
61
+ query.push('WHERE')
62
+ query.push(this._where.getSQL(options))
63
+ }
64
+
65
+ if (this._orderBy) {
66
+ query.push(this._orderBy.getSQL(options))
67
+ }
68
+
69
+
70
+ if (this._limit !== null) {
71
+ query.push('LIMIT ' + this._limit)
72
+ if (this._offset !== null && this._offset !== 0) {
73
+ query.push('OFFSET ' + this._offset)
74
+ }
75
+ }
76
+
77
+ return joinSQLQuery(query, ' ');
78
+ }
79
+
80
+ limit(limit: number|null, offset: number|null = null): this {
81
+ this._limit = limit;
82
+ this._offset = offset;
83
+ return this;
84
+ }
85
+
86
+ async fetch(): Promise<SQLResultNamespacedRow[]> {
87
+ const {query, params} = normalizeSQLQuery(this.getSQL())
88
+
89
+ console.log(query, params);
90
+ const [rows] = await Database.select(query, params, {nestTables: true});
91
+
92
+ // Now map aggregated queries to the correct namespace
93
+ for (const row of rows) {
94
+ if (row['']) {
95
+ for (const column in row['']) {
96
+ const splitted = column.split('__');
97
+ if (splitted.length <= 1) {
98
+ console.warn('Aggregated column without namespace', column)
99
+ continue;
100
+ }
101
+ const namespace = splitted[0];
102
+ const name = splitted[1];
103
+ row[namespace] = row[namespace] ?? {};
104
+ row[namespace][name] = row[''][column];
105
+ }
106
+ delete row[''];
107
+ }
108
+ }
109
+ return rows;
110
+ }
111
+
112
+ first(required: false): Promise<SQLResultNamespacedRow|null>
113
+ first(required: true): Promise<SQLResultNamespacedRow>
114
+ async first(required = true): Promise<SQLResultNamespacedRow|null> {
115
+ const rows = await this.limit(1).fetch();
116
+ if (rows.length === 0) {
117
+ if (required) {
118
+ throw new Error('Required ' + this._from)
119
+ }
120
+ return null;
121
+ }
122
+ return rows[0]
123
+ }
124
+
125
+ async count(): Promise<number> {
126
+ this._columns = [
127
+ new SQLSelectAs(
128
+ new SQLCount(),
129
+ new SQLAlias('c')
130
+ )
131
+ ]
132
+ this._offset = null;
133
+ this._limit = null;
134
+ this._orderBy = null;
135
+
136
+ const {query, params} = normalizeSQLQuery(this.getSQL());
137
+ console.log(query, params);
138
+
139
+ const [rows] = await Database.select(query, params, {nestTables: true});
140
+ if (rows.length === 1) {
141
+ const row = rows[0];
142
+ if ('' in row) {
143
+ const namespaced = row[''];
144
+ if ('c' in namespaced) {
145
+ const value = namespaced['c'];
146
+ if (typeof value === 'number' && Number.isInteger(value)) {
147
+ return value;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ console.warn('Invalid count SQL response', rows);
153
+ return 0;
154
+ }
155
+ }
156
+
157
+ export const SQLSelect = addOrderByHelpers(
158
+ addWhereHelpers(SelectBase)
159
+ )
160
+
@@ -0,0 +1,339 @@
1
+ import { SQLExpression, SQLExpressionOptions, SQLQuery, joinSQLQuery, normalizeSQLQuery } from "./SQLExpression";
2
+ import { SQLArray, SQLDynamicExpression, SQLNull, readDynamicSQLExpression } from "./SQLExpressions";
3
+
4
+ type GConstructor<T = {}> = new (...args: any[]) => T;
5
+ type Whereable = GConstructor<{ _where: SQLWhere|null }>;
6
+
7
+ export type ParseWhereArguments = [
8
+ where: SQLWhere
9
+ ] | [
10
+ whereOrColumn: SQLExpression,
11
+ sign: SQLWhereSign,
12
+ value: SQLDynamicExpression
13
+ ] | [
14
+ whereOrColumn: SQLExpression,
15
+ value: SQLDynamicExpression
16
+ ]
17
+
18
+ export function addWhereHelpers<TBase extends Whereable>(Base: TBase) {
19
+ return class extends Base {
20
+ parseWhere(...[whereOrColumn, signOrValue, value]: ParseWhereArguments): SQLWhere {
21
+ if (signOrValue === undefined) {
22
+ return whereOrColumn as SQLWhere;
23
+ }
24
+
25
+ if (value !== undefined) {
26
+ return new SQLWhereEqual(
27
+ whereOrColumn,
28
+ signOrValue as SQLWhereSign,
29
+ readDynamicSQLExpression(value)
30
+ )
31
+ }
32
+ return new SQLWhereEqual(
33
+ whereOrColumn,
34
+ SQLWhereSign.Equal,
35
+ readDynamicSQLExpression(signOrValue)
36
+ )
37
+ }
38
+
39
+ where<T>(this: T, ...args: ParseWhereArguments): T {
40
+ const w = (this as any).parseWhere(...args);
41
+ if (!(this as any)._where) {
42
+ (this as any)._where = w;
43
+ return this;
44
+ }
45
+ (this as any)._where = (this as any)._where.and(w);
46
+ return this;
47
+ }
48
+
49
+ andWhere(...args: ParseWhereArguments) {
50
+ return this.where(...args)
51
+ }
52
+
53
+ orWhere(...args: ParseWhereArguments) {
54
+ const w = this.parseWhere(...args);
55
+ if (!this._where) {
56
+ this._where = w;
57
+ return this;
58
+ }
59
+ this._where = this._where.or(w);
60
+ return this;
61
+ }
62
+
63
+ whereNot(...args: ParseWhereArguments) {
64
+ const w = new SQLWhereNot(this.parseWhere(...args));
65
+ if (!this._where) {
66
+ this._where = w;
67
+ return this;
68
+ }
69
+ this._where = this._where.and(w);
70
+ return this;
71
+ }
72
+
73
+ andWhereNot(...args: ParseWhereArguments) {
74
+ return this.whereNot(...args)
75
+ }
76
+
77
+ orWhereNot(...args: ParseWhereArguments) {
78
+ const w = new SQLWhereNot(this.parseWhere(...args));
79
+ if (!this._where) {
80
+ this._where = w;
81
+ return this;
82
+ }
83
+ this._where = this._where.or(w);
84
+ return this;
85
+ }
86
+ }
87
+ }
88
+
89
+ export abstract class SQLWhere implements SQLExpression {
90
+ and(...where: SQLWhere[]): SQLWhere {
91
+ return new SQLWhereAnd([this, ...where]);
92
+ }
93
+
94
+ or(...where: SQLWhere[]): SQLWhere {
95
+ return new SQLWhereOr([this, ...where]);
96
+ }
97
+
98
+ get isSingle(): boolean {
99
+ return false;
100
+ }
101
+
102
+ abstract getSQL(options?: SQLExpressionOptions): SQLQuery
103
+ }
104
+
105
+ export enum SQLWhereSign {
106
+ Equal = '=',
107
+ Greater = '>',
108
+ Less = '<',
109
+ NotEqual = '!='
110
+ }
111
+
112
+ export class SQLWhereEqual extends SQLWhere {
113
+ column: SQLExpression;
114
+ sign = SQLWhereSign.Equal
115
+ value: SQLExpression;
116
+
117
+ constructor (column: SQLExpression, sign: SQLWhereSign, value: SQLExpression)
118
+ constructor (column: SQLExpression, value: SQLExpression)
119
+ constructor (column: SQLExpression, signOrValue: SQLExpression | SQLWhereSign, value?: SQLExpression) {
120
+ super()
121
+ this.column = column;
122
+
123
+ if (value !== undefined) {
124
+ this.value = value;
125
+ this.sign = signOrValue as SQLWhereSign;
126
+ } else {
127
+ this.value = signOrValue as SQLExpression;
128
+ }
129
+ }
130
+
131
+ clone(): this {
132
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
133
+ const c = (new (this.constructor as any)(this.column, this.sign, this.value)) as this
134
+ Object.assign(c, this);
135
+ return c;
136
+ }
137
+
138
+ get isSingle(): boolean {
139
+ return true;
140
+ }
141
+
142
+ inverted(): this {
143
+ return this.clone().invert()
144
+ }
145
+
146
+ invert(): this {
147
+ switch (this.sign) {
148
+ case SQLWhereSign.Equal: this.sign = SQLWhereSign.NotEqual; break;
149
+ case SQLWhereSign.NotEqual: this.sign = SQLWhereSign.Equal; break;
150
+ case SQLWhereSign.Greater: this.sign = SQLWhereSign.Less; break;
151
+ case SQLWhereSign.Less: this.sign = SQLWhereSign.Greater; break;
152
+ }
153
+ return this;
154
+ }
155
+
156
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
157
+ if (this.value instanceof SQLArray) {
158
+ if (this.sign !== SQLWhereSign.Equal && this.sign !== SQLWhereSign.NotEqual) {
159
+ throw new Error('Unsupported sign for array: ' + this.sign);
160
+ }
161
+
162
+ return joinSQLQuery([
163
+ this.column.getSQL(options),
164
+ ` ${(this.sign === SQLWhereSign.NotEqual) ? 'NOT IN' : 'IN'} `,
165
+ this.value.getSQL(options)
166
+ ])
167
+ }
168
+
169
+
170
+ if (this.value instanceof SQLNull) {
171
+ if (this.sign !== SQLWhereSign.Equal && this.sign !== SQLWhereSign.NotEqual) {
172
+ throw new Error('Unsupported sign for NULL: ' + this.sign);
173
+ }
174
+
175
+ return joinSQLQuery([
176
+ this.column.getSQL(options),
177
+ ` IS ${(this.sign === SQLWhereSign.NotEqual) ? 'NOT ' : ''} `,
178
+ this.value.getSQL(options)
179
+ ])
180
+ }
181
+
182
+ return joinSQLQuery([
183
+ this.column.getSQL(options),
184
+ ` ${this.sign} `,
185
+ this.value.getSQL(options)
186
+ ])
187
+ }
188
+ }
189
+
190
+ export class SQLWhereLike extends SQLWhere {
191
+ column: SQLExpression;
192
+ notLike = false;
193
+ value: SQLExpression;
194
+
195
+ constructor (column: SQLExpression, value: SQLExpression) {
196
+ super()
197
+ this.column = column;
198
+ this.value = value
199
+ }
200
+
201
+ static escape(str: string) {
202
+ return str.replace(/([%_])/g, '\\$1')
203
+ }
204
+
205
+ clone(): this {
206
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
207
+ const c = (new (this.constructor as any)(this.column, this.value)) as this
208
+ Object.assign(c, this);
209
+ return c;
210
+ }
211
+
212
+ get isSingle(): boolean {
213
+ return true;
214
+ }
215
+
216
+ inverted(): this {
217
+ return this.clone().invert()
218
+ }
219
+
220
+ invert(): this {
221
+ this.notLike = !this.notLike
222
+ return this;
223
+ }
224
+
225
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
226
+ return joinSQLQuery([
227
+ this.column.getSQL(options),
228
+ ` ${this.notLike ? 'NOT LIKE' : 'LIKE'} `,
229
+ this.value.getSQL(options)
230
+ ])
231
+ }
232
+ }
233
+
234
+ export class SQLWhereExists extends SQLWhere {
235
+ subquery: SQLExpression;
236
+ notExists = false;
237
+
238
+ constructor (subquery: SQLExpression) {
239
+ super()
240
+ this.subquery = subquery;
241
+ }
242
+
243
+ clone(): this {
244
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
245
+ const c = (new (this.constructor as any)(this.subquery)) as this
246
+ Object.assign(c, this);
247
+ return c;
248
+ }
249
+
250
+ get isSingle(): boolean {
251
+ return true;
252
+ }
253
+
254
+ inverted(): this {
255
+ return this.clone().invert()
256
+ }
257
+
258
+ invert(): this {
259
+ this.notExists = !this.notExists
260
+ return this;
261
+ }
262
+
263
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
264
+ return joinSQLQuery([
265
+ `${this.notExists ? 'NOT EXISTS' : 'EXISTS'} (`,
266
+ this.subquery.getSQL({...options}),
267
+ `)`,
268
+ ])
269
+ }
270
+ }
271
+
272
+ export class SQLWhereAnd extends SQLWhere {
273
+ children: SQLWhere[]
274
+
275
+ constructor (children: SQLWhere[]) {
276
+ super()
277
+ this.children = children;
278
+ }
279
+
280
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
281
+ return joinSQLQuery(
282
+ this.children.map(c => {
283
+ if (c.isSingle) {
284
+ return c.getSQL(options)
285
+ }
286
+ return joinSQLQuery(['(', c.getSQL(options), ')'])
287
+ }),
288
+ ' AND '
289
+ )
290
+ }
291
+ }
292
+
293
+ export class SQLWhereOr extends SQLWhere {
294
+ children: SQLWhere[]
295
+
296
+ constructor (children: SQLWhere[]) {
297
+ super()
298
+ this.children = children;
299
+ }
300
+
301
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
302
+ return joinSQLQuery(
303
+ this.children.map(c => {
304
+ if (c.isSingle) {
305
+ return c.getSQL(options)
306
+ }
307
+ return joinSQLQuery(['(', c.getSQL(options), ')'])
308
+ }),
309
+ ' OR '
310
+ )
311
+ }
312
+ }
313
+
314
+ export class SQLWhereNot extends SQLWhere {
315
+ a: SQLWhere
316
+
317
+ constructor (a: SQLWhere) {
318
+ super()
319
+ this.a = a;
320
+ }
321
+
322
+ get isSingle(): boolean {
323
+ return this.a.isSingle;
324
+ }
325
+
326
+ getSQL(options?: SQLExpressionOptions): SQLQuery {
327
+ // Optimize query
328
+ if (this.a instanceof SQLWhereEqual) {
329
+ return this.a.inverted().getSQL(options);
330
+ }
331
+
332
+ const sqlA = normalizeSQLQuery(this.a.getSQL(options));
333
+
334
+ return {
335
+ query: `NOT (${sqlA.query})`,
336
+ params: sqlA.params
337
+ }
338
+ }
339
+ }
@@ -0,0 +1,264 @@
1
+ import { SimpleError } from "@simonbackx/simple-errors";
2
+ import { StamhoofdFilter, StamhoofdKeyFilterValue } from "@stamhoofd/structures";
3
+ import { SQL } from "../SQL";
4
+ import { SQLExpression } from "../SQLExpression";
5
+ import { SQLArray, SQLColumnExpression, SQLNull, SQLSafeValue, SQLScalarValue, scalarToSQLExpression, scalarToSQLJSONExpression } from "../SQLExpressions";
6
+ import { SQLJsonContains, SQLJsonOverlaps, SQLJsonSearch } from "../SQLJsonExpressions";
7
+ import { SQLSelect } from "../SQLSelect";
8
+ import { SQLWhere, SQLWhereAnd, SQLWhereEqual, SQLWhereExists, SQLWhereLike, SQLWhereNot, SQLWhereOr, SQLWhereSign } from "../SQLWhere";
9
+
10
+ export type SQLFilterCompiler = (filter: StamhoofdFilter, filters: SQLFilterDefinitions) => SQLWhere|null;
11
+ export type SQLFilterDefinitions = Record<string, SQLFilterCompiler>
12
+
13
+ export function andSQLFilterCompiler(filter: StamhoofdFilter, filters: SQLFilterDefinitions): SQLWhere {
14
+ const runners = compileSQLFilter(filter, filters);
15
+ return new SQLWhereAnd(runners)
16
+ }
17
+
18
+ export function orSQLFilterCompiler(filter: StamhoofdFilter, filters: SQLFilterDefinitions): SQLWhere {
19
+ const runners = compileSQLFilter(filter, filters);
20
+ return new SQLWhereOr(runners)
21
+ }
22
+
23
+ export function notSQLFilterCompiler(filter: StamhoofdFilter, filters: SQLFilterDefinitions): SQLWhere {
24
+ const andRunner = andSQLFilterCompiler(filter, filters);
25
+ return new SQLWhereNot(andRunner)
26
+ }
27
+
28
+ function guardScalar(s: any): asserts s is SQLScalarValue|null {
29
+ if (typeof s !== 'string' && typeof s !== 'number' && typeof s !== 'boolean' && !(s instanceof Date) && s !== null) {
30
+ throw new Error('Invalid scalar value')
31
+ }
32
+
33
+ }
34
+
35
+ function guardNotNullScalar(s: any): asserts s is SQLScalarValue {
36
+ if (typeof s !== 'string' && typeof s !== 'number' && typeof s !== 'boolean' && !(s instanceof Date)) {
37
+ throw new Error('Invalid scalar value')
38
+ }
39
+ }
40
+
41
+ function guardString(s: any): asserts s is string {
42
+ if (typeof s !== 'string') {
43
+ throw new Error('Invalid string value')
44
+ }
45
+ }
46
+
47
+ export function createSQLRelationFilterCompiler(baseSelect: InstanceType<typeof SQLSelect> & SQLExpression, definitions: SQLFilterDefinitions): SQLFilterCompiler {
48
+ return (filter: StamhoofdFilter) => {
49
+ const f = filter as any;
50
+
51
+ if ('$elemMatch' in f) {
52
+ const w = compileToSQLFilter(f['$elemMatch'], definitions)
53
+ const q = baseSelect.clone().where(w);
54
+ return new SQLWhereExists(q)
55
+ }
56
+
57
+ throw new Error('Invalid filter')
58
+ }
59
+ }
60
+
61
+ // Already joined, but creates a namespace
62
+ export function createSQLFilterNamespace(definitions: SQLFilterDefinitions): SQLFilterCompiler {
63
+ return (filter: StamhoofdFilter) => {
64
+ return andSQLFilterCompiler(filter, definitions)
65
+ }
66
+ }
67
+
68
+ export function createSQLExpressionFilterCompiler(sqlExpression: SQLExpression, normalizeValue?: (v: SQLScalarValue|null) => SQLScalarValue|null, isJSONValue = false, isJSONObject = false): SQLFilterCompiler {
69
+ const norm = normalizeValue ?? ((v) => v);
70
+ const convertToExpression = isJSONValue ? scalarToSQLJSONExpression : scalarToSQLExpression
71
+
72
+ return (filter: StamhoofdFilter, filters: SQLFilterDefinitions) => {
73
+ if (typeof filter === 'string' || typeof filter === 'number' || typeof filter === 'boolean' || filter === null || filter === undefined) {
74
+ filter = {
75
+ $eq: filter
76
+ }
77
+ }
78
+
79
+ if (Array.isArray(filter)) {
80
+ throw new Error('Unexpected array in filter')
81
+ }
82
+
83
+
84
+ const f = filter as any;
85
+
86
+ if ('$eq' in f) {
87
+ guardScalar(f.$eq);
88
+
89
+ if (isJSONObject) {
90
+ const v = norm(f.$eq);
91
+
92
+ // if (typeof v === 'string') {
93
+ // return new SQLWhereEqual(
94
+ // new SQLJsonSearch(sqlExpression, 'one', convertToExpression(v)),
95
+ // SQLWhereSign.NotEqual,
96
+ // new SQLNull()
97
+ // );
98
+ // }
99
+
100
+ // else
101
+ return new SQLWhereEqual(
102
+ new SQLJsonContains(
103
+ sqlExpression,
104
+ convertToExpression(JSON.stringify(v))
105
+ ),
106
+ SQLWhereSign.Equal,
107
+ new SQLSafeValue(1)
108
+ );
109
+ }
110
+ return new SQLWhereEqual(sqlExpression, SQLWhereSign.Equal, convertToExpression(norm(f.$eq)));
111
+ }
112
+
113
+ if ('$in' in f) {
114
+ if (!Array.isArray(f.$in)) {
115
+ throw new SimpleError({
116
+ code: 'invalid_filter',
117
+ message: 'Expected array at $in filter'
118
+ })
119
+ }
120
+
121
+ if (f.$in.length === 0) {
122
+ return new SQLWhereEqual(new SQLSafeValue(1), SQLWhereSign.Equal, new SQLSafeValue(0));
123
+ }
124
+
125
+ const v = f.$in.map(a => norm(a));
126
+
127
+ if (isJSONObject) {
128
+ // else
129
+ return new SQLWhereEqual(
130
+ new SQLJsonOverlaps(
131
+ sqlExpression,
132
+ convertToExpression(JSON.stringify(v))
133
+ ),
134
+ SQLWhereSign.Equal,
135
+ new SQLSafeValue(1)
136
+ );
137
+ }
138
+ return new SQLWhereEqual(sqlExpression, SQLWhereSign.Equal, new SQLArray(v));
139
+ }
140
+
141
+ if ('$neq' in f) {
142
+ guardScalar(f.$neq);
143
+
144
+ if (isJSONObject) {
145
+ const v = norm(f.$eq);
146
+
147
+ // if (typeof v === 'string') {
148
+ // return new SQLWhereEqual(
149
+ // new SQLJsonSearch(sqlExpression, 'one', convertToExpression(v)),
150
+ // SQLWhereSign.Equal,
151
+ // new SQLNull()
152
+ // );
153
+ // }
154
+
155
+ // else
156
+ return new SQLWhereEqual(
157
+ new SQLJsonContains(
158
+ sqlExpression,
159
+ convertToExpression(JSON.stringify(v))
160
+ ),
161
+ SQLWhereSign.Equal,
162
+ new SQLSafeValue(0)
163
+ );
164
+ }
165
+ return new SQLWhereEqual(sqlExpression, SQLWhereSign.NotEqual, convertToExpression(norm(f.$neq)));
166
+ }
167
+
168
+ if ('$gt' in f) {
169
+ guardScalar(f.$gt);
170
+
171
+ if (isJSONObject) {
172
+ throw new Error('Greater than is not supported in this place')
173
+ }
174
+
175
+ if (f.$gt === null) {
176
+ // > null is same as not equal to null (everything is larger than null in mysql) - to be consistent with order by behaviour
177
+ return new SQLWhereEqual(sqlExpression, SQLWhereSign.NotEqual, convertToExpression(null));
178
+ }
179
+ return new SQLWhereEqual(sqlExpression, SQLWhereSign.Greater, convertToExpression(norm(f.$gt)));
180
+ }
181
+
182
+ if ('$lt' in f) {
183
+ guardScalar(f.$lt);
184
+
185
+ if (isJSONObject) {
186
+ throw new Error('Less than is not supported in this place')
187
+ }
188
+
189
+ if (f.$lt === null) {
190
+ // < null is always nothing, there is nothing smaller than null in MySQL - to be consistent with order by behaviour
191
+ return new SQLWhereEqual(new SQLSafeValue(1), SQLWhereSign.Equal, new SQLSafeValue(0));
192
+ }
193
+ return new SQLWhereEqual(sqlExpression, SQLWhereSign.Less, convertToExpression(norm(f.$lt)));
194
+ }
195
+
196
+ if ('$contains' in f) {
197
+ guardString(f.$contains);
198
+
199
+ if (isJSONObject) {
200
+ return new SQLWhereEqual(
201
+ new SQLJsonSearch(
202
+ sqlExpression,
203
+ 'one',
204
+ convertToExpression(
205
+ '%'+SQLWhereLike.escape(f.$contains)+'%'
206
+ )
207
+ ),
208
+ SQLWhereSign.NotEqual,
209
+ new SQLNull()
210
+ );
211
+ }
212
+
213
+ return new SQLWhereLike(
214
+ sqlExpression,
215
+ convertToExpression(
216
+ '%'+SQLWhereLike.escape(f.$contains)+'%'
217
+ )
218
+ );
219
+ }
220
+
221
+ throw new Error('Invalid filter ' + JSON.stringify(f))
222
+ }
223
+ }
224
+
225
+ export function createSQLColumnFilterCompiler(name: string | SQLColumnExpression, normalizeValue?: (v: SQLScalarValue|null) => SQLScalarValue|null): SQLFilterCompiler {
226
+ const column = name instanceof SQLColumnExpression ? name : SQL.column(name);
227
+ return createSQLExpressionFilterCompiler(column, normalizeValue)
228
+ }
229
+
230
+ export const baseSQLFilterCompilers: SQLFilterDefinitions = {
231
+ '$and': andSQLFilterCompiler,
232
+ '$or': orSQLFilterCompiler,
233
+ '$not': notSQLFilterCompiler,
234
+ }
235
+
236
+ function compileSQLFilter(filter: StamhoofdFilter, definitions: SQLFilterDefinitions): SQLWhere[] {
237
+ if (filter === undefined) {
238
+ return [];
239
+ }
240
+
241
+ const runners: SQLWhere[] = []
242
+
243
+ for (const f of (Array.isArray(filter) ? filter : [filter])) {
244
+ if (!f) {
245
+ continue;
246
+ }
247
+ for (const key of Object.keys(f)) {
248
+ const filter = definitions[key];
249
+ if (!filter) {
250
+ throw new Error('Unsupported filter ' + key)
251
+ }
252
+
253
+ const s = filter(f[key] as StamhoofdFilter, definitions)
254
+ if (s === undefined || s === null) {
255
+ throw new Error('Unsupported filter value for ' + key)
256
+ }
257
+ runners.push(s);
258
+ }
259
+ }
260
+
261
+ return runners
262
+ }
263
+
264
+ export const compileToSQLFilter = andSQLFilterCompiler
@@ -0,0 +1,29 @@
1
+ import { PlainObject } from "@simonbackx/simple-encoding";
2
+ import { SortDefinition, SortList } from "@stamhoofd/structures";
3
+
4
+ import { SQLOrderBy, SQLOrderByDirection } from "../SQLOrderBy";
5
+
6
+ export type SQLSortDefinition<T, B extends PlainObject = PlainObject> = SortDefinition<T, B> & {
7
+ toSQL(direction: SQLOrderByDirection): SQLOrderBy
8
+ };
9
+
10
+ export type SQLSortDefinitions<T = any> = Record<string, SQLSortDefinition<T>>
11
+
12
+ export function compileToSQLSorter(sortBy: SortList, definitions: SQLSortDefinitions): SQLOrderBy {
13
+ const sorters: SQLOrderBy[] = [];
14
+
15
+ for (const s of sortBy) {
16
+ const d = definitions[s.key];
17
+ if (!d) {
18
+ throw new Error('Unknown sort key ' + s.key)
19
+ }
20
+
21
+ sorters.push(d.toSQL(s.order));
22
+ }
23
+
24
+ if (sorters.length === 0) {
25
+ throw new Error('No sortBy passed')
26
+ }
27
+
28
+ return SQLOrderBy.combine(sorters)
29
+ }