@expo/entity-database-adapter-knex-testing-utils 0.57.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.
@@ -0,0 +1,933 @@
1
+ import {
2
+ CompositeFieldHolder,
3
+ CompositeFieldValueHolder,
4
+ EntityQueryContext,
5
+ SingleFieldHolder,
6
+ SingleFieldValueHolder,
7
+ } from '@expo/entity';
8
+ import { NullsOrdering, OrderByOrdering, sql } from '@expo/entity-database-adapter-knex';
9
+ import { describe, expect, it, jest } from '@jest/globals';
10
+ import { instance, mock } from 'ts-mockito';
11
+ import { validate, version } from 'uuid';
12
+
13
+ import { StubPostgresDatabaseAdapter } from '../StubPostgresDatabaseAdapter';
14
+ import {
15
+ DateIDTestFields,
16
+ dateIDTestEntityConfiguration,
17
+ } from '../__testfixtures__/DateIDTestEntity';
18
+ import {
19
+ SimpleTestFields,
20
+ simpleTestEntityConfiguration,
21
+ } from '../__testfixtures__/SimpleTestEntity';
22
+ import { TestFields, testEntityConfiguration } from '../__testfixtures__/TestEntity';
23
+ import {
24
+ NumberKeyFields,
25
+ numberKeyEntityConfiguration,
26
+ } from '../__testfixtures__/TestEntityNumberKey';
27
+
28
+ // uuid keeps state internally for v7 generation, so we fix the time for all tests for consistent test results
29
+ const expectedTime = new Date('2024-06-03T20:16:33.761Z');
30
+ jest.useFakeTimers({
31
+ now: expectedTime,
32
+ });
33
+
34
+ describe(StubPostgresDatabaseAdapter, () => {
35
+ describe('fetchManyWhereAsync', () => {
36
+ it('fetches many where single', async () => {
37
+ const queryContext = instance(mock(EntityQueryContext));
38
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
39
+ testEntityConfiguration,
40
+ StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
41
+ testEntityConfiguration,
42
+ new Map([
43
+ [
44
+ testEntityConfiguration.tableName,
45
+ [
46
+ {
47
+ customIdField: 'hello',
48
+ testIndexedField: 'h1',
49
+ intField: 5,
50
+ stringField: 'huh',
51
+ dateField: new Date(),
52
+ nullableField: null,
53
+ },
54
+ {
55
+ customIdField: 'world',
56
+ testIndexedField: 'h2',
57
+ intField: 3,
58
+ stringField: 'wat',
59
+ dateField: new Date(),
60
+ nullableField: null,
61
+ },
62
+ ],
63
+ ],
64
+ ]),
65
+ ),
66
+ );
67
+
68
+ const results = await databaseAdapter.fetchManyWhereAsync(
69
+ queryContext,
70
+ new SingleFieldHolder('stringField'),
71
+ [new SingleFieldValueHolder('huh')],
72
+ );
73
+ expect(results.get(new SingleFieldValueHolder('huh'))).toHaveLength(1);
74
+ });
75
+
76
+ it('fetches many where composite', async () => {
77
+ const queryContext = instance(mock(EntityQueryContext));
78
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
79
+ testEntityConfiguration,
80
+ StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
81
+ testEntityConfiguration,
82
+ new Map([
83
+ [
84
+ testEntityConfiguration.tableName,
85
+ [
86
+ {
87
+ customIdField: 'hello',
88
+ testIndexedField: 'h1',
89
+ intField: 5,
90
+ stringField: 'huh',
91
+ dateField: new Date(),
92
+ nullableField: null,
93
+ },
94
+ {
95
+ customIdField: 'world',
96
+ testIndexedField: 'h2',
97
+ intField: 3,
98
+ stringField: 'wat',
99
+ dateField: new Date(),
100
+ nullableField: null,
101
+ },
102
+ ],
103
+ ],
104
+ ]),
105
+ ),
106
+ );
107
+
108
+ const results = await databaseAdapter.fetchManyWhereAsync(
109
+ queryContext,
110
+ new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
111
+ [new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 })],
112
+ );
113
+ expect(
114
+ results.get(new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 })),
115
+ ).toHaveLength(1);
116
+
117
+ const results2 = await databaseAdapter.fetchManyWhereAsync(
118
+ queryContext,
119
+ new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
120
+ [new CompositeFieldValueHolder({ stringField: 'not-in-db', intField: 5 })],
121
+ );
122
+ expect(
123
+ results2.get(new CompositeFieldValueHolder({ stringField: 'not-in-db', intField: 5 })),
124
+ ).toHaveLength(0);
125
+ });
126
+
127
+ it('handles duplicate tuples and filters them out', async () => {
128
+ const queryContext = instance(mock(EntityQueryContext));
129
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
130
+ testEntityConfiguration,
131
+ StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
132
+ testEntityConfiguration,
133
+ new Map([
134
+ [
135
+ testEntityConfiguration.tableName,
136
+ [
137
+ {
138
+ customIdField: 'id1',
139
+ testIndexedField: 'h1',
140
+ intField: 5,
141
+ stringField: 'test1',
142
+ dateField: new Date(),
143
+ nullableField: null,
144
+ },
145
+ {
146
+ customIdField: 'id2',
147
+ testIndexedField: 'h2',
148
+ intField: 10,
149
+ stringField: 'test2',
150
+ dateField: new Date(),
151
+ nullableField: null,
152
+ },
153
+ ],
154
+ ],
155
+ ]),
156
+ ),
157
+ );
158
+
159
+ // Fetch with duplicate field values - this will cause uniqBy to filter duplicates
160
+ const results = await databaseAdapter.fetchManyWhereAsync(
161
+ queryContext,
162
+ new SingleFieldHolder('customIdField'),
163
+ [
164
+ new SingleFieldValueHolder('id1'),
165
+ new SingleFieldValueHolder('id2'),
166
+ new SingleFieldValueHolder('id1'),
167
+ new SingleFieldValueHolder('id2'),
168
+ ],
169
+ );
170
+
171
+ // Should only get 2 unique results despite passing 4 values (2 duplicates)
172
+ expect(results.get(new SingleFieldValueHolder('id1'))).toHaveLength(1);
173
+ expect(results.get(new SingleFieldValueHolder('id2'))).toHaveLength(1);
174
+
175
+ // Verify the actual objects returned
176
+ const id1Results = results.get(new SingleFieldValueHolder('id1'));
177
+ expect(id1Results?.[0]).toMatchObject({ customIdField: 'id1', stringField: 'test1' });
178
+
179
+ const id2Results = results.get(new SingleFieldValueHolder('id2'));
180
+ expect(id2Results?.[0]).toMatchObject({ customIdField: 'id2', stringField: 'test2' });
181
+ });
182
+ });
183
+
184
+ describe('fetchOneWhereAsync', () => {
185
+ it('fetches one where single', async () => {
186
+ const queryContext = instance(mock(EntityQueryContext));
187
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
188
+ testEntityConfiguration,
189
+ StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
190
+ testEntityConfiguration,
191
+ new Map([
192
+ [
193
+ testEntityConfiguration.tableName,
194
+ [
195
+ {
196
+ customIdField: 'hello',
197
+ testIndexedField: 'h1',
198
+ intField: 5,
199
+ stringField: 'huh',
200
+ dateField: new Date(),
201
+ nullableField: null,
202
+ },
203
+ {
204
+ customIdField: 'world',
205
+ testIndexedField: 'h2',
206
+ intField: 3,
207
+ stringField: 'huh',
208
+ dateField: new Date(),
209
+ nullableField: null,
210
+ },
211
+ ],
212
+ ],
213
+ ]),
214
+ ),
215
+ );
216
+
217
+ const result = await databaseAdapter.fetchOneWhereAsync(
218
+ queryContext,
219
+ new SingleFieldHolder('stringField'),
220
+ new SingleFieldValueHolder('huh'),
221
+ );
222
+ expect(result).toMatchObject({
223
+ stringField: 'huh',
224
+ });
225
+ });
226
+
227
+ it('returns null when no record found', async () => {
228
+ const queryContext = instance(mock(EntityQueryContext));
229
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
230
+ testEntityConfiguration,
231
+ new Map(),
232
+ );
233
+
234
+ const result = await databaseAdapter.fetchOneWhereAsync(
235
+ queryContext,
236
+ new SingleFieldHolder('stringField'),
237
+ new SingleFieldValueHolder('huh'),
238
+ );
239
+ expect(result).toBeNull();
240
+ });
241
+
242
+ it('fetches one where composite', async () => {
243
+ const queryContext = instance(mock(EntityQueryContext));
244
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
245
+ testEntityConfiguration,
246
+ StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
247
+ testEntityConfiguration,
248
+ new Map([
249
+ [
250
+ testEntityConfiguration.tableName,
251
+ [
252
+ {
253
+ customIdField: 'hello',
254
+ testIndexedField: 'h1',
255
+ intField: 5,
256
+ stringField: 'huh',
257
+ dateField: new Date(),
258
+ nullableField: null,
259
+ },
260
+ {
261
+ customIdField: 'world',
262
+ testIndexedField: 'h2',
263
+ intField: 5,
264
+ stringField: 'huh',
265
+ dateField: new Date(),
266
+ nullableField: null,
267
+ },
268
+ ],
269
+ ],
270
+ ]),
271
+ ),
272
+ );
273
+
274
+ const result = await databaseAdapter.fetchOneWhereAsync(
275
+ queryContext,
276
+ new CompositeFieldHolder<TestFields, 'customIdField'>(['stringField', 'intField']),
277
+ new CompositeFieldValueHolder({ stringField: 'huh', intField: 5 }),
278
+ );
279
+ expect(result).toMatchObject({
280
+ stringField: 'huh',
281
+ intField: 5,
282
+ });
283
+ });
284
+ });
285
+
286
+ describe('fetchManyByFieldEqualityConjunctionAsync', () => {
287
+ it('supports conjuntions and query modifiers', async () => {
288
+ const queryContext = instance(mock(EntityQueryContext));
289
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
290
+ testEntityConfiguration,
291
+ StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
292
+ testEntityConfiguration,
293
+ new Map([
294
+ [
295
+ testEntityConfiguration.tableName,
296
+ [
297
+ {
298
+ customIdField: 'hello',
299
+ testIndexedField: 'h1',
300
+ intField: 3,
301
+ stringField: 'a',
302
+ dateField: new Date(),
303
+ nullableField: null,
304
+ },
305
+ {
306
+ customIdField: 'world',
307
+ testIndexedField: 'h2',
308
+ intField: 3,
309
+ stringField: 'b',
310
+ dateField: new Date(),
311
+ nullableField: null,
312
+ },
313
+ {
314
+ customIdField: 'world',
315
+ testIndexedField: 'h2',
316
+ intField: 3,
317
+ stringField: 'c',
318
+ dateField: new Date(),
319
+ nullableField: null,
320
+ },
321
+ ],
322
+ ],
323
+ ]),
324
+ ),
325
+ );
326
+
327
+ const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
328
+ queryContext,
329
+ [
330
+ {
331
+ fieldName: 'customIdField',
332
+ fieldValues: ['hello', 'world'],
333
+ },
334
+ {
335
+ fieldName: 'intField',
336
+ fieldValue: 3,
337
+ },
338
+ ],
339
+ {
340
+ limit: 2,
341
+ offset: 1,
342
+ orderBy: [
343
+ {
344
+ fieldName: 'stringField',
345
+ order: OrderByOrdering.DESCENDING,
346
+ },
347
+ ],
348
+ },
349
+ );
350
+
351
+ expect(results).toHaveLength(2);
352
+ expect(results.map((e) => e.stringField)).toEqual(['b', 'a']);
353
+ });
354
+
355
+ it('supports multiple order bys', async () => {
356
+ const queryContext = instance(mock(EntityQueryContext));
357
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
358
+ testEntityConfiguration,
359
+ StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
360
+ testEntityConfiguration,
361
+ new Map([
362
+ [
363
+ testEntityConfiguration.tableName,
364
+ [
365
+ {
366
+ customIdField: 'hello',
367
+ testIndexedField: 'h1',
368
+ intField: 3,
369
+ stringField: 'a',
370
+ dateField: new Date(),
371
+ nullableField: null,
372
+ },
373
+ {
374
+ customIdField: 'world',
375
+ testIndexedField: 'h2',
376
+ intField: 3,
377
+ stringField: 'b',
378
+ dateField: new Date(),
379
+ nullableField: null,
380
+ },
381
+ {
382
+ customIdField: 'world',
383
+ testIndexedField: 'h2',
384
+ intField: 3,
385
+ stringField: 'c',
386
+ dateField: new Date(),
387
+ nullableField: null,
388
+ },
389
+ ],
390
+ ],
391
+ ]),
392
+ ),
393
+ );
394
+
395
+ const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
396
+ queryContext,
397
+ [
398
+ {
399
+ fieldName: 'intField',
400
+ fieldValue: 3,
401
+ },
402
+ ],
403
+ {
404
+ orderBy: [
405
+ {
406
+ fieldName: 'intField',
407
+ order: OrderByOrdering.DESCENDING,
408
+ },
409
+ {
410
+ fieldName: 'stringField',
411
+ order: OrderByOrdering.DESCENDING,
412
+ },
413
+ ],
414
+ },
415
+ );
416
+
417
+ expect(results).toHaveLength(3);
418
+ expect(results.map((e) => e.stringField)).toEqual(['c', 'b', 'a']);
419
+ });
420
+
421
+ it('supports null field values', async () => {
422
+ const queryContext = instance(mock(EntityQueryContext));
423
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
424
+ testEntityConfiguration,
425
+ StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
426
+ testEntityConfiguration,
427
+ new Map([
428
+ [
429
+ testEntityConfiguration.tableName,
430
+ [
431
+ {
432
+ customIdField: '1',
433
+ testIndexedField: 'h1',
434
+ intField: 1,
435
+ stringField: 'a',
436
+ dateField: new Date(),
437
+ nullableField: 'a',
438
+ },
439
+ {
440
+ customIdField: '2',
441
+ testIndexedField: 'h2',
442
+ intField: 2,
443
+ stringField: 'a',
444
+ dateField: new Date(),
445
+ nullableField: 'b',
446
+ },
447
+ {
448
+ customIdField: '3',
449
+ testIndexedField: 'h3',
450
+ intField: 3,
451
+ stringField: 'a',
452
+ dateField: new Date(),
453
+ nullableField: null,
454
+ },
455
+ {
456
+ customIdField: '4',
457
+ testIndexedField: 'h4',
458
+ intField: 4,
459
+ stringField: 'b',
460
+ dateField: new Date(),
461
+ nullableField: null,
462
+ },
463
+ ],
464
+ ],
465
+ ]),
466
+ ),
467
+ );
468
+
469
+ const results = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
470
+ queryContext,
471
+ [{ fieldName: 'nullableField', fieldValue: null }],
472
+ {},
473
+ );
474
+ expect(results).toHaveLength(2);
475
+ expect(results[0]!.nullableField).toBeNull();
476
+
477
+ const results2 = await databaseAdapter.fetchManyByFieldEqualityConjunctionAsync(
478
+ queryContext,
479
+ [
480
+ { fieldName: 'nullableField', fieldValues: ['a', null] },
481
+ { fieldName: 'stringField', fieldValue: 'a' },
482
+ ],
483
+ {
484
+ orderBy: [
485
+ {
486
+ fieldName: 'nullableField',
487
+ order: OrderByOrdering.DESCENDING,
488
+ },
489
+ ],
490
+ },
491
+ );
492
+ expect(results2).toHaveLength(2);
493
+ expect(results2.map((e) => e.nullableField)).toEqual([null, 'a']);
494
+ });
495
+ });
496
+
497
+ describe('fetchManyByRawWhereClauseAsync', () => {
498
+ it('throws because it is unsupported', async () => {
499
+ const queryContext = instance(mock(EntityQueryContext));
500
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
501
+ testEntityConfiguration,
502
+ new Map(),
503
+ );
504
+ await expect(
505
+ databaseAdapter.fetchManyByRawWhereClauseAsync(queryContext, '', [], {}),
506
+ ).rejects.toThrow();
507
+ });
508
+ });
509
+
510
+ describe('fetchManyBySQLFragmentAsync', () => {
511
+ it('throws because it is unsupported', async () => {
512
+ const queryContext = instance(mock(EntityQueryContext));
513
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
514
+ testEntityConfiguration,
515
+ new Map(),
516
+ );
517
+ await expect(
518
+ databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, sql``, {}),
519
+ ).rejects.toThrow();
520
+ });
521
+ });
522
+
523
+ describe('insertAsync', () => {
524
+ it('inserts a record', async () => {
525
+ const queryContext = instance(mock(EntityQueryContext));
526
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
527
+ testEntityConfiguration,
528
+ new Map(),
529
+ );
530
+ const result = await databaseAdapter.insertAsync(queryContext, {
531
+ stringField: 'hello',
532
+ });
533
+ expect(result).toMatchObject({
534
+ stringField: 'hello',
535
+ });
536
+
537
+ expect(
538
+ databaseAdapter.getObjectCollectionForTable(testEntityConfiguration.tableName),
539
+ ).toHaveLength(1);
540
+ });
541
+
542
+ it('inserts a record with valid v7 id', async () => {
543
+ const queryContext = instance(mock(EntityQueryContext));
544
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
545
+ testEntityConfiguration,
546
+ new Map(),
547
+ );
548
+ const result = await databaseAdapter.insertAsync(queryContext, {
549
+ stringField: 'hello',
550
+ });
551
+
552
+ const ts = getTimeFromUUIDv7(result.customIdField);
553
+ expect(ts).toEqual(expectedTime);
554
+ });
555
+ });
556
+
557
+ describe('updateAsync', () => {
558
+ it('updates a record', async () => {
559
+ const queryContext = instance(mock(EntityQueryContext));
560
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
561
+ testEntityConfiguration,
562
+ StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
563
+ testEntityConfiguration,
564
+ new Map([
565
+ [
566
+ testEntityConfiguration.tableName,
567
+ [
568
+ {
569
+ customIdField: 'hello',
570
+ testIndexedField: 'h1',
571
+ intField: 3,
572
+ stringField: 'a',
573
+ dateField: new Date(),
574
+ nullableField: null,
575
+ },
576
+ ],
577
+ ],
578
+ ]),
579
+ ),
580
+ );
581
+ const result = await databaseAdapter.updateAsync(queryContext, 'customIdField', 'hello', {
582
+ stringField: 'b',
583
+ });
584
+ expect(result).toMatchObject({
585
+ stringField: 'b',
586
+ testIndexedField: 'h1',
587
+ });
588
+ });
589
+
590
+ it('throws error when empty update to match common DBMS behavior', async () => {
591
+ const queryContext = instance(mock(EntityQueryContext));
592
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
593
+ testEntityConfiguration,
594
+ StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
595
+ testEntityConfiguration,
596
+ new Map([
597
+ [
598
+ testEntityConfiguration.tableName,
599
+ [
600
+ {
601
+ customIdField: 'hello',
602
+ testIndexedField: 'h1',
603
+ intField: 3,
604
+ stringField: 'a',
605
+ dateField: new Date(),
606
+ nullableField: null,
607
+ },
608
+ ],
609
+ ],
610
+ ]),
611
+ ),
612
+ );
613
+ await expect(
614
+ databaseAdapter.updateAsync(queryContext, 'customIdField', 'hello', {}),
615
+ ).rejects.toThrow(`Empty update (custom_id = hello)`);
616
+ });
617
+
618
+ it('throws error when updating nonexistent record', async () => {
619
+ const queryContext = instance(mock(EntityQueryContext));
620
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
621
+ testEntityConfiguration,
622
+ StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
623
+ testEntityConfiguration,
624
+ new Map([
625
+ [
626
+ testEntityConfiguration.tableName,
627
+ [
628
+ {
629
+ customIdField: 'existing-id',
630
+ testIndexedField: 'h1',
631
+ intField: 3,
632
+ stringField: 'a',
633
+ dateField: new Date(),
634
+ nullableField: null,
635
+ },
636
+ ],
637
+ ],
638
+ ]),
639
+ ),
640
+ );
641
+
642
+ // Try to update a record that doesn't exist
643
+ await expect(
644
+ databaseAdapter.updateAsync(
645
+ queryContext,
646
+ 'customIdField',
647
+ 'nonexistent-id', // This ID doesn't exist in the data store
648
+ { stringField: 'updated' },
649
+ ),
650
+ ).rejects.toThrow('Empty results from database adapter update');
651
+ });
652
+ });
653
+
654
+ describe('deleteAsync', () => {
655
+ it('deletes an object', async () => {
656
+ const queryContext = instance(mock(EntityQueryContext));
657
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
658
+ testEntityConfiguration,
659
+ StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
660
+ testEntityConfiguration,
661
+ new Map([
662
+ [
663
+ testEntityConfiguration.tableName,
664
+ [
665
+ {
666
+ customIdField: 'hello',
667
+ testIndexedField: 'h1',
668
+ intField: 3,
669
+ stringField: 'a',
670
+ dateField: new Date(),
671
+ nullableField: null,
672
+ },
673
+ ],
674
+ ],
675
+ ]),
676
+ ),
677
+ );
678
+
679
+ await databaseAdapter.deleteAsync(queryContext, 'customIdField', 'hello');
680
+
681
+ expect(
682
+ databaseAdapter.getObjectCollectionForTable(testEntityConfiguration.tableName),
683
+ ).toHaveLength(0);
684
+ });
685
+
686
+ it('handles deletion of nonexistent record gracefully', async () => {
687
+ const queryContext = instance(mock(EntityQueryContext));
688
+ const databaseAdapter = new StubPostgresDatabaseAdapter<TestFields, 'customIdField'>(
689
+ testEntityConfiguration,
690
+ StubPostgresDatabaseAdapter.convertFieldObjectsToDataStore(
691
+ testEntityConfiguration,
692
+ new Map([
693
+ [
694
+ testEntityConfiguration.tableName,
695
+ [
696
+ {
697
+ customIdField: 'existing-id',
698
+ testIndexedField: 'h1',
699
+ intField: 3,
700
+ stringField: 'a',
701
+ dateField: new Date(),
702
+ nullableField: null,
703
+ },
704
+ ],
705
+ ],
706
+ ]),
707
+ ),
708
+ );
709
+
710
+ // Delete a record that doesn't exist
711
+ // Unlike update, delete doesn't throw an error when the record doesn't exist
712
+ await databaseAdapter.deleteAsync(
713
+ queryContext,
714
+ 'customIdField',
715
+ 'nonexistent-id', // This ID doesn't exist in the data store
716
+ );
717
+ });
718
+ });
719
+
720
+ it('supports string and number IDs', async () => {
721
+ const queryContext = instance(mock(EntityQueryContext));
722
+ const databaseAdapter1 = new StubPostgresDatabaseAdapter<SimpleTestFields, 'id'>(
723
+ simpleTestEntityConfiguration,
724
+ new Map(),
725
+ );
726
+ const insertedObject1 = await databaseAdapter1.insertAsync(queryContext, {});
727
+ expect(typeof insertedObject1.id).toBe('string');
728
+
729
+ const databaseAdapter2 = new StubPostgresDatabaseAdapter<NumberKeyFields, 'id'>(
730
+ numberKeyEntityConfiguration,
731
+ new Map(),
732
+ );
733
+ const insertedObject2 = await databaseAdapter2.insertAsync(queryContext, {});
734
+ expect(typeof insertedObject2.id).toBe('number');
735
+
736
+ const databaseAdapter3 = new StubPostgresDatabaseAdapter<DateIDTestFields, 'id'>(
737
+ dateIDTestEntityConfiguration,
738
+ new Map(),
739
+ );
740
+ await expect(databaseAdapter3.insertAsync(queryContext, {})).rejects.toThrow(
741
+ 'Unsupported ID type for StubPostgresDatabaseAdapter: DateField',
742
+ );
743
+ });
744
+ });
745
+
746
+ describe('compareByOrderBys', () => {
747
+ describe('comparison with default nulls ordering', () => {
748
+ it.each([
749
+ // Default nulls ordering: NULLS FIRST for DESC, NULLS LAST for ASC
750
+ // nulls compare with 0
751
+ [OrderByOrdering.DESCENDING, null, 0, -1],
752
+ [OrderByOrdering.ASCENDING, null, 0, 1],
753
+ [OrderByOrdering.DESCENDING, 0, null, 1],
754
+ [OrderByOrdering.ASCENDING, 0, null, -1],
755
+ // nulls compare with nulls (fall through to next order by, which is empty, so 0)
756
+ [OrderByOrdering.DESCENDING, null, null, 0],
757
+ [OrderByOrdering.ASCENDING, null, null, 0],
758
+ // nulls compare with -1
759
+ [OrderByOrdering.DESCENDING, null, -1, -1],
760
+ [OrderByOrdering.ASCENDING, null, -1, 1],
761
+ [OrderByOrdering.DESCENDING, -1, null, 1],
762
+ [OrderByOrdering.ASCENDING, -1, null, -1],
763
+ // basic compares
764
+ [OrderByOrdering.ASCENDING, 'a', 'b', -1],
765
+ [OrderByOrdering.ASCENDING, 'b', 'a', 1],
766
+ [OrderByOrdering.DESCENDING, 'a', 'b', 1],
767
+ [OrderByOrdering.DESCENDING, 'b', 'a', -1],
768
+ ])('case (%p; %p; %p)', (order, v1, v2, expectedResult) => {
769
+ expect(
770
+ StubPostgresDatabaseAdapter['compareByOrderBys'](
771
+ [
772
+ {
773
+ columnName: 'hello',
774
+ order,
775
+ nulls: undefined,
776
+ },
777
+ ],
778
+ {
779
+ hello: v1,
780
+ },
781
+ {
782
+ hello: v2,
783
+ },
784
+ ),
785
+ ).toEqual(expectedResult);
786
+ });
787
+ it('throws for SQL fragment order by', () => {
788
+ expect(() =>
789
+ StubPostgresDatabaseAdapter['compareByOrderBys'](
790
+ [
791
+ {
792
+ columnFragment: sql`some_function(col)`,
793
+ order: OrderByOrdering.ASCENDING,
794
+ nulls: undefined,
795
+ },
796
+ ],
797
+ { hello: 'a' },
798
+ { hello: 'b' },
799
+ ),
800
+ ).toThrow('SQL fragment order by not supported for StubDatabaseAdapter');
801
+ });
802
+ it('works for empty', () => {
803
+ expect(
804
+ StubPostgresDatabaseAdapter['compareByOrderBys'](
805
+ [],
806
+ {
807
+ hello: 'test',
808
+ },
809
+ {
810
+ hello: 'blah',
811
+ },
812
+ ),
813
+ ).toEqual(0);
814
+ });
815
+ });
816
+
817
+ describe('comparison with explicit nulls ordering', () => {
818
+ it.each([
819
+ // ASC NULLS FIRST: nulls come before non-nulls
820
+ [OrderByOrdering.ASCENDING, NullsOrdering.FIRST, null, 0, -1],
821
+ [OrderByOrdering.ASCENDING, NullsOrdering.FIRST, 0, null, 1],
822
+ // ASC NULLS LAST: nulls come after non-nulls (same as default ASC)
823
+ [OrderByOrdering.ASCENDING, NullsOrdering.LAST, null, 0, 1],
824
+ [OrderByOrdering.ASCENDING, NullsOrdering.LAST, 0, null, -1],
825
+ // DESC NULLS FIRST: nulls come before non-nulls (same as default DESC)
826
+ [OrderByOrdering.DESCENDING, NullsOrdering.FIRST, null, 0, -1],
827
+ [OrderByOrdering.DESCENDING, NullsOrdering.FIRST, 0, null, 1],
828
+ // DESC NULLS LAST: nulls come after non-nulls
829
+ [OrderByOrdering.DESCENDING, NullsOrdering.LAST, null, 0, 1],
830
+ [OrderByOrdering.DESCENDING, NullsOrdering.LAST, 0, null, -1],
831
+ // Non-null values should not be affected by nulls ordering
832
+ [OrderByOrdering.ASCENDING, NullsOrdering.FIRST, 'a', 'b', -1],
833
+ [OrderByOrdering.ASCENDING, NullsOrdering.LAST, 'a', 'b', -1],
834
+ [OrderByOrdering.DESCENDING, NullsOrdering.FIRST, 'a', 'b', 1],
835
+ [OrderByOrdering.DESCENDING, NullsOrdering.LAST, 'a', 'b', 1],
836
+ // Both null should fall through to next order by (which is empty, so 0)
837
+ [OrderByOrdering.ASCENDING, NullsOrdering.FIRST, null, null, 0],
838
+ [OrderByOrdering.DESCENDING, NullsOrdering.LAST, null, null, 0],
839
+ ])('case (%p; nulls %p; %p; %p)', (order, nulls, v1, v2, expectedResult) => {
840
+ expect(
841
+ StubPostgresDatabaseAdapter['compareByOrderBys'](
842
+ [
843
+ {
844
+ columnName: 'hello',
845
+ order,
846
+ nulls,
847
+ },
848
+ ],
849
+ {
850
+ hello: v1,
851
+ },
852
+ {
853
+ hello: v2,
854
+ },
855
+ ),
856
+ ).toEqual(expectedResult);
857
+ });
858
+ });
859
+
860
+ describe('recursing', () => {
861
+ it('recurses to secondary order by on tie', () => {
862
+ expect(
863
+ StubPostgresDatabaseAdapter['compareByOrderBys'](
864
+ [
865
+ {
866
+ columnName: 'hello',
867
+ order: OrderByOrdering.ASCENDING,
868
+ nulls: undefined,
869
+ },
870
+ {
871
+ columnName: 'world',
872
+ order: OrderByOrdering.ASCENDING,
873
+ nulls: undefined,
874
+ },
875
+ ],
876
+ {
877
+ hello: 'a',
878
+ world: 1,
879
+ },
880
+ {
881
+ hello: 'a',
882
+ world: 2,
883
+ },
884
+ ),
885
+ ).toEqual(-1);
886
+ });
887
+
888
+ it('recurses to secondary order by when both values are null', () => {
889
+ expect(
890
+ StubPostgresDatabaseAdapter['compareByOrderBys'](
891
+ [
892
+ {
893
+ columnName: 'hello',
894
+ order: OrderByOrdering.ASCENDING,
895
+ nulls: undefined,
896
+ },
897
+ {
898
+ columnName: 'world',
899
+ order: OrderByOrdering.ASCENDING,
900
+ nulls: undefined,
901
+ },
902
+ ],
903
+ {
904
+ hello: null,
905
+ world: 1,
906
+ },
907
+ {
908
+ hello: null,
909
+ world: 2,
910
+ },
911
+ ),
912
+ ).toEqual(-1);
913
+ });
914
+ });
915
+ });
916
+
917
+ /**
918
+ * Returns the Date object encoded in the first 48 bits of the given UUIDv7.
919
+ * @throws TypeError if the UUID is not version 7
920
+ */
921
+ function getTimeFromUUIDv7(uuid: string): Date {
922
+ if (!(validate(uuid) && version(uuid) === 7)) {
923
+ throw new TypeError(`Invalid UUID: ${uuid}`);
924
+ }
925
+
926
+ // The first 48 bits = 12 hex characters of the UUID encode the timestamp in big endian
927
+ const hexCharacters = uuid.replaceAll('-', '').split('', 12);
928
+ const milliseconds = hexCharacters.reduce(
929
+ (milliseconds, character) => milliseconds * 16 + parseInt(character, 16),
930
+ 0,
931
+ );
932
+ return new Date(milliseconds);
933
+ }