@teamkeel/functions-runtime 0.242.1 → 0.244.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamkeel/functions-runtime",
3
- "version": "0.242.1",
3
+ "version": "0.244.0",
4
4
  "description": "Internal package used by @teamkeel/sdk",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/ModelAPI.js CHANGED
@@ -1,13 +1,41 @@
1
+ const { getDatabase } = require("./database");
2
+ const { QueryBuilder } = require("./QueryBuilder");
3
+ const { QueryContext } = require("./QueryContext");
1
4
  const { applyWhereConditions } = require("./applyWhereConditions");
5
+ const { applyJoins } = require("./applyJoins");
2
6
  const { camelCaseObject, snakeCaseObject } = require("./casing");
3
- const { QueryBuilder } = require("./QueryBuilder");
4
- const { getDatabase } = require("./database");
7
+
8
+ /**
9
+ * RelationshipConfig is a simple representation of a model field that
10
+ * is a relationship. It is used by applyJoins and applyWhereConditions
11
+ * to build the correct query.
12
+ * @typedef {{
13
+ * relationshipType: "belongsTo" | "hasMany",
14
+ * foreignKey: string,
15
+ * referencesTable: string,
16
+ * }} RelationshipConfig
17
+ *
18
+ * TableConfig is an object where the keys are relationship field names
19
+ * (which don't exist in the database) and the values are RelationshipConfig
20
+ * objects describing that relationship.
21
+ * @typedef {Object.<string, RelationshipConfig} TableConfig
22
+ *
23
+ * TableConfigMap is mapping of database table names to TableConfig objects
24
+ * @typedef {Object.<string, TableConfig>} TableConfigMap
25
+ */
5
26
 
6
27
  class ModelAPI {
7
- constructor(tableName, defaultValues, db) {
8
- this._tableName = tableName;
9
- this._defaultValues = defaultValues;
28
+ /**
29
+ * @param {string} tableName The name of the table this API is for
30
+ * @param {Function} defaultValues A function that returns the default values for a row in this table
31
+ * @param {import("kysely").Kysely} db
32
+ * @param {TableConfigMap} tableConfigMap
33
+ */
34
+ constructor(tableName, defaultValues, db, tableConfigMap = {}) {
10
35
  this._db = db || getDatabase();
36
+ this._defaultValues = defaultValues;
37
+ this._tableName = tableName;
38
+ this._tableConfigMap = tableConfigMap;
11
39
  }
12
40
 
13
41
  async create(values) {
@@ -21,70 +49,85 @@ class ModelAPI {
21
49
  )
22
50
  .returningAll()
23
51
  .executeTakeFirst();
52
+
24
53
  return camelCaseObject(row);
25
54
  }
26
55
 
27
56
  async findOne(where) {
28
- const row = await this._db
57
+ let builder = this._db
29
58
  .selectFrom(this._tableName)
30
- .selectAll()
31
- .where((qb) => {
32
- return applyWhereConditions(qb, where);
33
- })
34
- .executeTakeFirst();
59
+ .distinctOn(`${this._tableName}.id`)
60
+ .selectAll(this._tableName);
61
+
62
+ const context = new QueryContext([this._tableName], this._tableConfigMap);
63
+
64
+ builder = applyJoins(context, builder, where);
65
+ builder = applyWhereConditions(context, builder, where);
66
+
67
+ const row = await builder.executeTakeFirst();
35
68
  if (!row) {
36
69
  return null;
37
70
  }
71
+
38
72
  return camelCaseObject(row);
39
73
  }
40
74
 
41
75
  async findMany(where) {
42
- let query = this._db.selectFrom(this._tableName).selectAll();
76
+ let builder = this._db
77
+ .selectFrom(this._tableName)
78
+ .distinctOn(`${this._tableName}.id`)
79
+ .selectAll(this._tableName);
43
80
 
44
- // only apply constraints if there are keys in the where
45
- if (Object.keys(where).length > 0) {
46
- query = query.where((qb) => {
47
- return applyWhereConditions(qb, where);
48
- });
49
- }
81
+ const context = new QueryContext([this._tableName], this._tableConfigMap);
50
82
 
51
- const rows = await query.execute();
83
+ builder = applyJoins(context, builder, where);
84
+ builder = applyWhereConditions(context, builder, where);
52
85
 
86
+ const rows = await builder.orderBy("id").execute();
53
87
  return rows.map((x) => camelCaseObject(x));
54
88
  }
55
89
 
56
90
  async update(where, values) {
57
- const row = await this._db
58
- .updateTable(this._tableName)
59
- .returningAll()
60
- .set(snakeCaseObject(values))
61
- .where((qb) => {
62
- return applyWhereConditions(qb, where);
63
- })
64
- .executeTakeFirstOrThrow();
91
+ let builder = this._db.updateTable(this._tableName).returningAll();
92
+
93
+ builder = builder.set(snakeCaseObject(values));
94
+
95
+ const context = new QueryContext([this._tableName], this._tableConfigMap);
96
+
97
+ // TODO: support joins for update
98
+ builder = applyWhereConditions(context, builder, where);
99
+
100
+ const row = await builder.executeTakeFirstOrThrow();
65
101
  return camelCaseObject(row);
66
102
  }
67
103
 
68
104
  async delete(where) {
69
- const row = await this._db
70
- .deleteFrom(this._tableName)
71
- .returning(["id"])
72
- .where((qb) => {
73
- return applyWhereConditions(qb, where);
74
- })
75
- .executeTakeFirstOrThrow();
105
+ let builder = this._db.deleteFrom(this._tableName).returning(["id"]);
106
+
107
+ const context = new QueryContext([this._tableName], this._tableConfigMap);
108
+
109
+ // TODO: support joins for delete
110
+ builder = applyWhereConditions(context, builder, where);
111
+
112
+ const row = await builder.executeTakeFirstOrThrow();
76
113
  return row.id;
77
114
  }
78
115
 
79
- where(conditions) {
80
- const q = this._db
116
+ where(where) {
117
+ let builder = this._db
81
118
  .selectFrom(this._tableName)
82
- .selectAll()
83
- .where((qb) => {
84
- return applyWhereConditions(qb, conditions);
85
- });
86
- return new QueryBuilder(q);
119
+ .distinctOn(`${this._tableName}.id`)
120
+ .selectAll(this._tableName);
121
+
122
+ const context = new QueryContext([this._tableName], this._tableConfigMap);
123
+
124
+ builder = applyJoins(context, builder, where);
125
+ builder = applyWhereConditions(context, builder, where);
126
+
127
+ return new QueryBuilder(context, builder);
87
128
  }
88
129
  }
89
130
 
90
- module.exports.ModelAPI = ModelAPI;
131
+ module.exports = {
132
+ ModelAPI,
133
+ };
@@ -7,36 +7,72 @@ const KSUID = require("ksuid");
7
7
  process.env.DB_CONN_TYPE = "pg";
8
8
  process.env.DB_CONN = `postgresql://postgres:postgres@localhost:5432/functions-runtime`;
9
9
 
10
- let api;
10
+ let personAPI;
11
+ let postAPI;
11
12
 
12
13
  beforeEach(async () => {
13
14
  const db = getDatabase();
14
15
 
15
16
  await sql`
16
- DROP TABLE IF EXISTS model_api_test;
17
- CREATE TABLE model_api_test(
17
+ DROP TABLE IF EXISTS post;
18
+ DROP TABLE IF EXISTS person;
19
+ CREATE TABLE person(
18
20
  id text PRIMARY KEY,
19
21
  name text UNIQUE,
20
22
  married boolean,
21
23
  favourite_number integer,
22
24
  date timestamp
23
25
  );
26
+ CREATE TABLE post(
27
+ id text PRIMARY KEY,
28
+ title text,
29
+ author_id text references person(id)
30
+ );
24
31
  `.execute(db);
25
32
 
26
- api = new ModelAPI(
27
- "model_api_test",
33
+ const tableConfigMap = {
34
+ person: {
35
+ posts: {
36
+ relationshipType: "hasMany",
37
+ foreignKey: "author_id",
38
+ referencesTable: "post",
39
+ },
40
+ },
41
+ post: {
42
+ author: {
43
+ relationshipType: "belongsTo",
44
+ foreignKey: "author_id",
45
+ referencesTable: "person",
46
+ },
47
+ },
48
+ };
49
+
50
+ personAPI = new ModelAPI(
51
+ "person",
28
52
  () => {
29
53
  return {
30
54
  id: KSUID.randomSync().string,
31
55
  date: new Date("2022-01-01"),
32
56
  };
33
57
  },
34
- db
58
+ db,
59
+ tableConfigMap
60
+ );
61
+
62
+ postAPI = new ModelAPI(
63
+ "post",
64
+ () => {
65
+ return {
66
+ id: KSUID.randomSync().string,
67
+ };
68
+ },
69
+ db,
70
+ tableConfigMap
35
71
  );
36
72
  });
37
73
 
38
74
  test("ModelAPI.create", async () => {
39
- const row = await api.create({
75
+ const row = await personAPI.create({
40
76
  name: "Jim",
41
77
  married: false,
42
78
  favouriteNumber: 10,
@@ -49,58 +85,77 @@ test("ModelAPI.create", async () => {
49
85
  });
50
86
 
51
87
  test("ModelAPI.create - throws if database constraint fails", async () => {
52
- const row = await api.create({
88
+ const row = await personAPI.create({
53
89
  name: "Jim",
54
90
  married: false,
55
91
  favouriteNumber: 10,
56
92
  });
57
- const promise = api.create({
93
+ const promise = personAPI.create({
58
94
  id: row.id,
59
95
  name: "Jim",
60
96
  married: false,
61
97
  favouriteNumber: 10,
62
98
  });
63
99
  await expect(promise).rejects.toThrow(
64
- `duplicate key value violates unique constraint "model_api_test_pkey"`
100
+ `duplicate key value violates unique constraint "person_pkey"`
65
101
  );
66
102
  });
67
103
 
68
104
  test("ModelAPI.findOne", async () => {
69
- const created = await api.create({
105
+ const created = await personAPI.create({
70
106
  name: "Jim",
71
107
  married: false,
72
108
  favouriteNumber: 10,
73
109
  });
74
- const row = await api.findOne({
110
+ const row = await personAPI.findOne({
75
111
  id: created.id,
76
112
  });
77
113
  expect(row).toEqual(created);
78
114
  });
79
115
 
116
+ test("ModelAPI.findOne - relationships - one to many", async () => {
117
+ const person = await personAPI.create({
118
+ name: "Jim",
119
+ married: false,
120
+ favouriteNumber: 10,
121
+ });
122
+ const post = await postAPI.create({
123
+ title: "My Post",
124
+ authorId: person.id,
125
+ });
126
+ const row = await personAPI.findOne({
127
+ posts: {
128
+ id: post.id,
129
+ },
130
+ });
131
+ expect(row.name).toEqual("Jim");
132
+ expect(row.id).toEqual(person.id);
133
+ });
134
+
80
135
  test("ModelAPI.findOne - return null if not found", async () => {
81
- const row = await api.findOne({
136
+ const row = await personAPI.findOne({
82
137
  id: "doesntexist",
83
138
  });
84
139
  expect(row).toEqual(null);
85
140
  });
86
141
 
87
142
  test("ModelAPI.findMany", async () => {
88
- const jim = await api.create({
143
+ const jim = await personAPI.create({
89
144
  name: "Jim",
90
145
  married: false,
91
146
  favouriteNumber: 10,
92
147
  });
93
- const bob = await api.create({
148
+ const bob = await personAPI.create({
94
149
  name: "Bob",
95
150
  married: true,
96
151
  favouriteNumber: 11,
97
152
  });
98
- const sally = await api.create({
153
+ const sally = await personAPI.create({
99
154
  name: "Sally",
100
155
  married: true,
101
156
  favouriteNumber: 12,
102
157
  });
103
- const rows = await api.findMany({
158
+ const rows = await personAPI.findMany({
104
159
  married: true,
105
160
  });
106
161
  expect(rows.length).toEqual(2);
@@ -108,26 +163,26 @@ test("ModelAPI.findMany", async () => {
108
163
  });
109
164
 
110
165
  test("ModelAPI.findMany - no where conditions", async () => {
111
- const jim = await api.create({
166
+ const jim = await personAPI.create({
112
167
  name: "Jim",
113
168
  });
114
- await api.create({
169
+ await personAPI.create({
115
170
  name: "Bob",
116
171
  });
117
172
 
118
- const rows = await api.findMany({});
173
+ const rows = await personAPI.findMany({});
119
174
 
120
175
  expect(rows.length).toEqual(2);
121
176
  });
122
177
 
123
178
  test("ModelAPI.findMany - startsWith", async () => {
124
- const jim = await api.create({
179
+ const jim = await personAPI.create({
125
180
  name: "Jim",
126
181
  });
127
- await api.create({
182
+ await personAPI.create({
128
183
  name: "Bob",
129
184
  });
130
- const rows = await api.findMany({
185
+ const rows = await personAPI.findMany({
131
186
  name: {
132
187
  startsWith: "Ji",
133
188
  },
@@ -137,13 +192,13 @@ test("ModelAPI.findMany - startsWith", async () => {
137
192
  });
138
193
 
139
194
  test("ModelAPI.findMany - endsWith", async () => {
140
- const jim = await api.create({
195
+ const jim = await personAPI.create({
141
196
  name: "Jim",
142
197
  });
143
- await api.create({
198
+ await personAPI.create({
144
199
  name: "Bob",
145
200
  });
146
- const rows = await api.findMany({
201
+ const rows = await personAPI.findMany({
147
202
  name: {
148
203
  endsWith: "im",
149
204
  },
@@ -153,16 +208,16 @@ test("ModelAPI.findMany - endsWith", async () => {
153
208
  });
154
209
 
155
210
  test("ModelAPI.findMany - contains", async () => {
156
- const billy = await api.create({
211
+ const billy = await personAPI.create({
157
212
  name: "Billy",
158
213
  });
159
- const sally = await api.create({
214
+ const sally = await personAPI.create({
160
215
  name: "Sally",
161
216
  });
162
- await api.create({
217
+ await personAPI.create({
163
218
  name: "Jim",
164
219
  });
165
- const rows = await api.findMany({
220
+ const rows = await personAPI.findMany({
166
221
  name: {
167
222
  contains: "ll",
168
223
  },
@@ -172,16 +227,16 @@ test("ModelAPI.findMany - contains", async () => {
172
227
  });
173
228
 
174
229
  test("ModelAPI.findMany - oneOf", async () => {
175
- const billy = await api.create({
230
+ const billy = await personAPI.create({
176
231
  name: "Billy",
177
232
  });
178
- const sally = await api.create({
233
+ const sally = await personAPI.create({
179
234
  name: "Sally",
180
235
  });
181
- await api.create({
236
+ await personAPI.create({
182
237
  name: "Jim",
183
238
  });
184
- const rows = await api.findMany({
239
+ const rows = await personAPI.findMany({
185
240
  name: {
186
241
  oneOf: ["Billy", "Sally"],
187
242
  },
@@ -191,13 +246,13 @@ test("ModelAPI.findMany - oneOf", async () => {
191
246
  });
192
247
 
193
248
  test("ModelAPI.findMany - greaterThan", async () => {
194
- await api.create({
249
+ await personAPI.create({
195
250
  favouriteNumber: 1,
196
251
  });
197
- const p = await api.create({
252
+ const p = await personAPI.create({
198
253
  favouriteNumber: 2,
199
254
  });
200
- const rows = await api.findMany({
255
+ const rows = await personAPI.findMany({
201
256
  favouriteNumber: {
202
257
  greaterThan: 1,
203
258
  },
@@ -207,16 +262,16 @@ test("ModelAPI.findMany - greaterThan", async () => {
207
262
  });
208
263
 
209
264
  test("ModelAPI.findMany - greaterThanOrEquals", async () => {
210
- await api.create({
265
+ await personAPI.create({
211
266
  favouriteNumber: 1,
212
267
  });
213
- const p = await api.create({
268
+ const p = await personAPI.create({
214
269
  favouriteNumber: 2,
215
270
  });
216
- const p2 = await api.create({
271
+ const p2 = await personAPI.create({
217
272
  favouriteNumber: 3,
218
273
  });
219
- const rows = await api.findMany({
274
+ const rows = await personAPI.findMany({
220
275
  favouriteNumber: {
221
276
  greaterThanOrEquals: 2,
222
277
  },
@@ -226,13 +281,13 @@ test("ModelAPI.findMany - greaterThanOrEquals", async () => {
226
281
  });
227
282
 
228
283
  test("ModelAPI.findMany - lessThan", async () => {
229
- const p = await api.create({
284
+ const p = await personAPI.create({
230
285
  favouriteNumber: 1,
231
286
  });
232
- await api.create({
287
+ await personAPI.create({
233
288
  favouriteNumber: 2,
234
289
  });
235
- const rows = await api.findMany({
290
+ const rows = await personAPI.findMany({
236
291
  favouriteNumber: {
237
292
  lessThan: 2,
238
293
  },
@@ -242,16 +297,16 @@ test("ModelAPI.findMany - lessThan", async () => {
242
297
  });
243
298
 
244
299
  test("ModelAPI.findMany - lessThanOrEquals", async () => {
245
- const p = await api.create({
300
+ const p = await personAPI.create({
246
301
  favouriteNumber: 1,
247
302
  });
248
- const p2 = await api.create({
303
+ const p2 = await personAPI.create({
249
304
  favouriteNumber: 2,
250
305
  });
251
- await api.create({
306
+ await personAPI.create({
252
307
  favouriteNumber: 3,
253
308
  });
254
- const rows = await api.findMany({
309
+ const rows = await personAPI.findMany({
255
310
  favouriteNumber: {
256
311
  lessThanOrEquals: 2,
257
312
  },
@@ -261,13 +316,13 @@ test("ModelAPI.findMany - lessThanOrEquals", async () => {
261
316
  });
262
317
 
263
318
  test("ModelAPI.findMany - before", async () => {
264
- const p = await api.create({
319
+ const p = await personAPI.create({
265
320
  date: new Date("2022-01-01"),
266
321
  });
267
- await api.create({
322
+ await personAPI.create({
268
323
  date: new Date("2022-01-02"),
269
324
  });
270
- const rows = await api.findMany({
325
+ const rows = await personAPI.findMany({
271
326
  date: {
272
327
  before: new Date("2022-01-02"),
273
328
  },
@@ -277,16 +332,16 @@ test("ModelAPI.findMany - before", async () => {
277
332
  });
278
333
 
279
334
  test("ModelAPI.findMany - onOrBefore", async () => {
280
- const p = await api.create({
335
+ const p = await personAPI.create({
281
336
  date: new Date("2022-01-01"),
282
337
  });
283
- const p2 = await api.create({
338
+ const p2 = await personAPI.create({
284
339
  date: new Date("2022-01-02"),
285
340
  });
286
- await api.create({
341
+ await personAPI.create({
287
342
  date: new Date("2022-01-03"),
288
343
  });
289
- const rows = await api.findMany({
344
+ const rows = await personAPI.findMany({
290
345
  date: {
291
346
  onOrBefore: new Date("2022-01-02"),
292
347
  },
@@ -296,13 +351,13 @@ test("ModelAPI.findMany - onOrBefore", async () => {
296
351
  });
297
352
 
298
353
  test("ModelAPI.findMany - after", async () => {
299
- await api.create({
354
+ await personAPI.create({
300
355
  date: new Date("2022-01-01"),
301
356
  });
302
- const p = await api.create({
357
+ const p = await personAPI.create({
303
358
  date: new Date("2022-01-02"),
304
359
  });
305
- const rows = await api.findMany({
360
+ const rows = await personAPI.findMany({
306
361
  date: {
307
362
  after: new Date("2022-01-01"),
308
363
  },
@@ -312,16 +367,16 @@ test("ModelAPI.findMany - after", async () => {
312
367
  });
313
368
 
314
369
  test("ModelAPI.findMany - onOrAfter", async () => {
315
- await api.create({
370
+ await personAPI.create({
316
371
  date: new Date("2022-01-01"),
317
372
  });
318
- const p = await api.create({
373
+ const p = await personAPI.create({
319
374
  date: new Date("2022-01-02"),
320
375
  });
321
- const p2 = await api.create({
376
+ const p2 = await personAPI.create({
322
377
  date: new Date("2022-01-03"),
323
378
  });
324
- const rows = await api.findMany({
379
+ const rows = await personAPI.findMany({
325
380
  date: {
326
381
  onOrAfter: new Date("2022-01-02"),
327
382
  },
@@ -331,13 +386,13 @@ test("ModelAPI.findMany - onOrAfter", async () => {
331
386
  });
332
387
 
333
388
  test("ModelAPI.findMany - equals", async () => {
334
- const p = await api.create({
389
+ const p = await personAPI.create({
335
390
  name: "Jim",
336
391
  });
337
- await api.create({
392
+ await personAPI.create({
338
393
  name: "Sally",
339
394
  });
340
- const rows = await api.findMany({
395
+ const rows = await personAPI.findMany({
341
396
  name: {
342
397
  equals: "Jim",
343
398
  },
@@ -347,13 +402,13 @@ test("ModelAPI.findMany - equals", async () => {
347
402
  });
348
403
 
349
404
  test("ModelAPI.findMany - notEquals", async () => {
350
- const p = await api.create({
405
+ const p = await personAPI.create({
351
406
  name: "Jim",
352
407
  });
353
- await api.create({
408
+ await personAPI.create({
354
409
  name: "Sally",
355
410
  });
356
- const rows = await api.findMany({
411
+ const rows = await personAPI.findMany({
357
412
  name: {
358
413
  notEquals: "Sally",
359
414
  },
@@ -363,23 +418,23 @@ test("ModelAPI.findMany - notEquals", async () => {
363
418
  });
364
419
 
365
420
  test("ModelAPI.findMany - complex query", async () => {
366
- const p = await api.create({
421
+ const p = await personAPI.create({
367
422
  name: "Jake",
368
423
  favouriteNumber: 8,
369
424
  date: new Date("2021-12-31"),
370
425
  });
371
- await api.create({
426
+ await personAPI.create({
372
427
  name: "Jane",
373
428
  favouriteNumber: 12,
374
429
  date: new Date("2022-01-11"),
375
430
  });
376
- const p2 = await api.create({
431
+ const p2 = await personAPI.create({
377
432
  name: "Billy",
378
433
  favouriteNumber: 16,
379
434
  date: new Date("2022-01-05"),
380
435
  });
381
436
 
382
- const rows = await api
437
+ const rows = await personAPI
383
438
  // Will match Jake
384
439
  .where({
385
440
  name: {
@@ -402,18 +457,113 @@ test("ModelAPI.findMany - complex query", async () => {
402
457
  expect(rows.map((x) => x.id).sort()).toEqual([p.id, p2.id].sort());
403
458
  });
404
459
 
460
+ test("ModelAPI.findMany - relationships - one to many", async () => {
461
+ const person = await personAPI.create({
462
+ name: "Jim",
463
+ });
464
+ const person2 = await personAPI.create({
465
+ name: "Bob",
466
+ });
467
+ const post1 = await postAPI.create({
468
+ title: "My First Post",
469
+ authorId: person.id,
470
+ });
471
+ const post2 = await postAPI.create({
472
+ title: "My Second Post",
473
+ authorId: person.id,
474
+ });
475
+ await postAPI.create({
476
+ title: "My Third Post",
477
+ authorId: person2.id,
478
+ });
479
+
480
+ const posts = await postAPI.findMany({
481
+ author: {
482
+ name: "Jim",
483
+ },
484
+ });
485
+ expect(posts.length).toEqual(2);
486
+ expect(posts.map((x) => x.id).sort()).toEqual([post1.id, post2.id].sort());
487
+ });
488
+
489
+ test("ModelAPI.findMany - relationships - many to one", async () => {
490
+ const person = await personAPI.create({
491
+ name: "Jim",
492
+ });
493
+ await postAPI.create({
494
+ title: "My First Post",
495
+ authorId: person.id,
496
+ });
497
+ await postAPI.create({
498
+ title: "My Second Post",
499
+ authorId: person.id,
500
+ });
501
+ await postAPI.create({
502
+ title: "My Second Post",
503
+ authorId: person.id,
504
+ });
505
+
506
+ const people = await personAPI.findMany({
507
+ posts: {
508
+ title: {
509
+ startsWith: "My ",
510
+ endsWith: " Post",
511
+ },
512
+ },
513
+ });
514
+
515
+ // This tests that many to one joins work for findMany() but also
516
+ // that the same row is not returned more than once e.g. Jim has
517
+ // three posts but should only be returned once
518
+ expect(people.length).toEqual(1);
519
+ expect(people[0].id).toEqual(person.id);
520
+ });
521
+
522
+ test("ModelAPI.findMany - relationships - duplicate joins handled", async () => {
523
+ const person = await personAPI.create({
524
+ name: "Jim",
525
+ });
526
+ const person2 = await personAPI.create({
527
+ name: "Bob",
528
+ });
529
+ const post1 = await postAPI.create({
530
+ title: "My First Post",
531
+ authorId: person.id,
532
+ });
533
+ const post2 = await postAPI.create({
534
+ title: "My Second Post",
535
+ authorId: person2.id,
536
+ });
537
+
538
+ const posts = await postAPI
539
+ .where({
540
+ author: {
541
+ name: "Jim",
542
+ },
543
+ })
544
+ .orWhere({
545
+ author: {
546
+ name: "Bob",
547
+ },
548
+ })
549
+ .findMany();
550
+
551
+ expect(posts.length).toEqual(2);
552
+ expect(posts.map((x) => x.id).sort()).toEqual([post1.id, post2.id].sort());
553
+ });
554
+
405
555
  test("ModelAPI.update", async () => {
406
- let jim = await api.create({
556
+ let jim = await personAPI.create({
407
557
  name: "Jim",
408
558
  married: false,
409
559
  favouriteNumber: 10,
410
560
  });
411
- let bob = await api.create({
561
+ let bob = await personAPI.create({
412
562
  name: "Bob",
413
563
  married: false,
414
564
  favouriteNumber: 11,
415
565
  });
416
- jim = await api.update(
566
+ jim = await personAPI.update(
417
567
  {
418
568
  id: jim.id,
419
569
  },
@@ -424,12 +574,12 @@ test("ModelAPI.update", async () => {
424
574
  expect(jim.married).toEqual(true);
425
575
  expect(jim.name).toEqual("Jim");
426
576
 
427
- bob = await api.findOne({ id: bob.id });
577
+ bob = await personAPI.findOne({ id: bob.id });
428
578
  expect(bob.married).toEqual(false);
429
579
  });
430
580
 
431
581
  test("ModelAPI.update - throws if not found", async () => {
432
- const result = api.update(
582
+ const result = personAPI.update(
433
583
  {
434
584
  id: "doesntexist",
435
585
  },
@@ -441,14 +591,14 @@ test("ModelAPI.update - throws if not found", async () => {
441
591
  });
442
592
 
443
593
  test("ModelAPI.delete", async () => {
444
- const jim = await api.create({
594
+ const jim = await personAPI.create({
445
595
  name: "Jim",
446
596
  });
447
597
  const id = jim.id;
448
- const deletedId = await api.delete({
598
+ const deletedId = await personAPI.delete({
449
599
  name: "Jim",
450
600
  });
451
601
 
452
602
  expect(deletedId).toEqual(id);
453
- await expect(api.findOne({ id })).resolves.toEqual(null);
603
+ await expect(personAPI.findOne({ id })).resolves.toEqual(null);
454
604
  });
@@ -1,27 +1,40 @@
1
1
  const { applyWhereConditions } = require("./applyWhereConditions");
2
+ const { applyJoins } = require("./applyJoins");
2
3
  const { camelCaseObject } = require("./casing");
3
4
 
4
5
  class QueryBuilder {
5
- constructor(db) {
6
+ /**
7
+ * @param {import("./QueryContext").QueryContext} context
8
+ * @param {import("kysely").Kysely} db
9
+ */
10
+ constructor(context, db) {
11
+ this._context = context;
6
12
  this._db = db;
7
13
  }
8
14
 
9
- where(conditions) {
10
- const q = this._db.where((qb) => {
11
- return applyWhereConditions(qb, conditions);
12
- });
13
- return new QueryBuilder(q);
15
+ where(where) {
16
+ const context = this._context.clone();
17
+
18
+ let builder = applyJoins(context, this._db, where);
19
+ builder = applyWhereConditions(context, builder, where);
20
+
21
+ return new QueryBuilder(context, builder);
14
22
  }
15
23
 
16
- orWhere(conditions) {
17
- const q = this._db.orWhere((qb) => {
18
- return applyWhereConditions(qb, conditions);
24
+ orWhere(where) {
25
+ const context = this._context.clone();
26
+
27
+ let builder = applyJoins(context, this._db, where);
28
+
29
+ builder = builder.orWhere((qb) => {
30
+ return applyWhereConditions(context, qb, where);
19
31
  });
20
- return new QueryBuilder(q);
32
+
33
+ return new QueryBuilder(context, builder);
21
34
  }
22
35
 
23
36
  async findMany() {
24
- const rows = await this._db.execute();
37
+ const rows = await this._db.orderBy("id").execute();
25
38
  return rows.map((x) => camelCaseObject(x));
26
39
  }
27
40
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * QueryContext is used to store state about the current query, for example
3
+ * which joins have already been applied. It is used by applyJoins and
4
+ * applyWhereConditions to generate consistent table aliases for joins.
5
+ *
6
+ * This class has the concept of a "table path". This is just a list of tables, starting
7
+ * with some "root" table and ending with the table we're currently joining to. So
8
+ * for example if we started with a "product" table and joined from there to "order_item"
9
+ * and then to "order" and then to "customer" the table path would be:
10
+ * ["product", "order_item", "order", "customer"]
11
+ * At this point the "current" table is "customer" and it's alias would be:
12
+ * "product$order_item$order$customer"
13
+ */
14
+ class QueryContext {
15
+ /**
16
+ * @param {string[]} tablePath This is the path from the "root" table to the "current table".
17
+ * @param {import("./ModelAPI").TableConfigMap} tableConfigMap
18
+ * @param {string[]} joins
19
+ */
20
+ constructor(tablePath, tableConfigMap, joins = []) {
21
+ this._tablePath = tablePath;
22
+ this._tableConfigMap = tableConfigMap;
23
+ this._joins = joins;
24
+ }
25
+
26
+ clone() {
27
+ return new QueryContext([...this._tablePath], this._tableConfigMap, [
28
+ ...this._joins,
29
+ ]);
30
+ }
31
+
32
+ /**
33
+ * Returns true if, given the current table path, a join to the given
34
+ * table has already been added.
35
+ * @param {string} table
36
+ * @returns {boolean}
37
+ */
38
+ hasJoin(table) {
39
+ const alias = joinAlias([...this._tablePath, table]);
40
+ return this._joins.includes(alias);
41
+ }
42
+
43
+ /**
44
+ * Adds table to the QueryContext's path and registers the join,
45
+ * calls fn, then pops the table off the path.
46
+ * @param {string} table
47
+ * @param {Function} fn
48
+ */
49
+ withJoin(table, fn) {
50
+ this._tablePath.push(table);
51
+ this._joins.push(this.tableAlias());
52
+
53
+ fn();
54
+
55
+ // Don't change the _joins list, we want to remember those
56
+ this._tablePath.pop();
57
+ }
58
+
59
+ /**
60
+ * Returns the alias that will be used for the current table
61
+ * @returns {string}
62
+ */
63
+ tableAlias() {
64
+ return joinAlias(this._tablePath);
65
+ }
66
+
67
+ /**
68
+ * Returns the current table name
69
+ * @returns {string}
70
+ */
71
+ tableName() {
72
+ return this._tablePath[this._tablePath.length - 1];
73
+ }
74
+
75
+ /**
76
+ * Return the TableConfig for the current table
77
+ * @returns {import("./ModelAPI").TableConfig | undefined}
78
+ */
79
+ tableConfig() {
80
+ return this._tableConfigMap[this.tableName()];
81
+ }
82
+ }
83
+
84
+ function joinAlias(tablePath) {
85
+ return tablePath.join("$");
86
+ }
87
+
88
+ module.exports = {
89
+ QueryContext,
90
+ };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Adds the joins required by the where conditions to the given
3
+ * Kysely instance and returns the resulting new Kysely instance.
4
+ * @param {import("./QueryContext").QueryContext} context
5
+ * @param {import("kysely").Kysely} qb
6
+ * @param {Object} where
7
+ * @returns {import("kysely").Kysely}
8
+ */
9
+ function applyJoins(context, qb, where) {
10
+ const conf = context.tableConfig();
11
+ if (!conf) {
12
+ return qb;
13
+ }
14
+
15
+ const srcTableName = context.tableName();
16
+
17
+ for (const key of Object.keys(where)) {
18
+ const rel = conf[key];
19
+ if (!rel) {
20
+ continue;
21
+ }
22
+
23
+ const targetTable = rel.referencesTable;
24
+
25
+ if (context.hasJoin(targetTable)) {
26
+ continue;
27
+ }
28
+
29
+ context.withJoin(targetTable, () => {
30
+ switch (rel.relationshipType) {
31
+ case "hasMany":
32
+ // For hasMany the primary key is on the source table
33
+ // and the foreign key is on the target table
34
+ qb = qb.innerJoin(
35
+ `${targetTable} as ${context.tableAlias()}`,
36
+ `${srcTableName}.id`,
37
+ `${context.tableAlias()}.${rel.foreignKey}`
38
+ );
39
+ break;
40
+
41
+ case "belongsTo":
42
+ // For belongsTo the primary key is on the target table
43
+ // and the foreign key is on the source table
44
+ qb = qb.innerJoin(
45
+ `${targetTable} as ${context.tableAlias()}`,
46
+ `${srcTableName}.${rel.foreignKey}`,
47
+ `${context.tableAlias()}.id`
48
+ );
49
+ break;
50
+ default:
51
+ throw new Error(`unknown relationshipType: ${rel.relationshipType}`);
52
+ }
53
+
54
+ // Keep traversing through the where conditions to see if
55
+ // more joins need to be applied
56
+ qb = applyJoins(context, qb, where[key]);
57
+ });
58
+ }
59
+
60
+ return qb;
61
+ }
62
+
63
+ module.exports = {
64
+ applyJoins,
65
+ };
@@ -1,5 +1,5 @@
1
- const { snakeCase } = require("./casing");
2
1
  const { sql } = require("kysely");
2
+ const { snakeCase } = require("./casing");
3
3
 
4
4
  const opMapping = {
5
5
  startsWith: { op: "like", value: (v) => `${v}%` },
@@ -18,10 +18,30 @@ const opMapping = {
18
18
  notEquals: { op: sql`is distinct from` },
19
19
  };
20
20
 
21
- function applyWhereConditions(qb, where) {
21
+ /**
22
+ * Applies the given where conditions to the provided Kysely
23
+ * instance and returns the resulting new Kysely instance.
24
+ * @param {import("./QueryContext").QueryContext} context
25
+ * @param {import("kysely").Kysely} qb
26
+ * @param {Object} where
27
+ * @returns {import("kysely").Kysely}
28
+ */
29
+ function applyWhereConditions(context, qb, where) {
30
+ const conf = context.tableConfig();
31
+
22
32
  for (const key of Object.keys(where)) {
23
33
  const v = where[key];
24
- const fieldName = snakeCase(key);
34
+
35
+ // Handle nested where conditions e.g. using a join table
36
+ if (conf && conf[key]) {
37
+ const rel = conf[key];
38
+ context.withJoin(rel.referencesTable, () => {
39
+ qb = applyWhereConditions(context, qb, v);
40
+ });
41
+ continue;
42
+ }
43
+
44
+ const fieldName = `${context.tableAlias()}.${snakeCase(key)}`;
25
45
 
26
46
  if (Object.prototype.toString.call(v) !== "[object Object]") {
27
47
  qb = qb.where(fieldName, "=", v);
@@ -45,4 +65,6 @@ function applyWhereConditions(qb, where) {
45
65
  return qb;
46
66
  }
47
67
 
48
- module.exports.applyWhereConditions = applyWhereConditions;
68
+ module.exports = {
69
+ applyWhereConditions,
70
+ };