@onchaindb/sdk 2.3.0 → 4.0.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/.claude/settings.local.json +2 -1
- package/README.md +161 -1
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -0
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/model.d.ts +99 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +218 -0
- package/dist/model.js.map +1 -0
- package/dist/query-sdk/QueryBuilder.d.ts +1 -1
- package/dist/query-sdk/QueryBuilder.d.ts.map +1 -1
- package/dist/query-sdk/QueryBuilder.js +2 -1
- package/dist/query-sdk/QueryBuilder.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +14 -0
- package/src/index.ts +14 -0
- package/src/model.ts +359 -0
- package/src/query-sdk/QueryBuilder.ts +4 -3
- package/src/query-sdk/tests/Joins.test.ts +154 -0
- package/src/tests/model.test.ts +885 -0
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModelDelegate vs QueryBuilder — getQueryRequest() parity
|
|
3
|
+
*
|
|
4
|
+
* Each test runs both code paths (Prisma-like facade and raw QueryBuilder)
|
|
5
|
+
* through a CapturingHttpClient that records the request body sent to the server.
|
|
6
|
+
* We then assert they are identical.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ModelDelegate } from '../model';
|
|
10
|
+
import { QueryBuilder, FieldConditionBuilder, LogicalOperator } from '../query-sdk';
|
|
11
|
+
import { OnDBClient } from '../client';
|
|
12
|
+
|
|
13
|
+
const APP = 'test_app';
|
|
14
|
+
const URL = 'http://localhost:9092';
|
|
15
|
+
const COLLECTION = 'users';
|
|
16
|
+
|
|
17
|
+
// ── Capturing HTTP client ─────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
class CapturingHttpClient {
|
|
20
|
+
lastRequest: any = null;
|
|
21
|
+
|
|
22
|
+
async post(_url: string, data: any, _headers?: any) {
|
|
23
|
+
this.lastRequest = data;
|
|
24
|
+
return { status: 200, data: { records: [], total: 0, page: 0, limit: 10 } };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function makeSetup(collection = COLLECTION) {
|
|
31
|
+
const http = new CapturingHttpClient();
|
|
32
|
+
|
|
33
|
+
// Minimal OnDBClient mock — only what ModelDelegate needs
|
|
34
|
+
const client = {
|
|
35
|
+
queryBuilder: () => new QueryBuilder(http, URL, APP),
|
|
36
|
+
store: jest.fn(),
|
|
37
|
+
} as unknown as OnDBClient;
|
|
38
|
+
|
|
39
|
+
const delegate = new ModelDelegate(client, collection);
|
|
40
|
+
|
|
41
|
+
// Convenience: raw QB bound to the same http client + collection
|
|
42
|
+
const qb = () => new QueryBuilder(http, URL, APP).collection(collection);
|
|
43
|
+
|
|
44
|
+
return { http, delegate, qb };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── findMany ──────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe('findMany where operators', () => {
|
|
50
|
+
|
|
51
|
+
test('shorthand equality: { field: value }', async () => {
|
|
52
|
+
const { http, delegate, qb } = makeSetup();
|
|
53
|
+
|
|
54
|
+
await delegate.findMany({ where: { name: 'Alice' } });
|
|
55
|
+
const prismaReq = http.lastRequest;
|
|
56
|
+
|
|
57
|
+
const qbReq = qb()
|
|
58
|
+
.find(() => new FieldConditionBuilder('name').equals('Alice'))
|
|
59
|
+
.getQueryRequest();
|
|
60
|
+
|
|
61
|
+
expect(prismaReq).toEqual(qbReq);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('{ field: { equals } }', async () => {
|
|
65
|
+
const { http, delegate, qb } = makeSetup();
|
|
66
|
+
|
|
67
|
+
await delegate.findMany({ where: { name: { equals: 'Alice' } } });
|
|
68
|
+
|
|
69
|
+
const qbReq = qb()
|
|
70
|
+
.find(() => new FieldConditionBuilder('name').equals('Alice'))
|
|
71
|
+
.getQueryRequest();
|
|
72
|
+
|
|
73
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('{ field: { not } }', async () => {
|
|
77
|
+
const { http, delegate, qb } = makeSetup();
|
|
78
|
+
|
|
79
|
+
await delegate.findMany({ where: { status: { not: 'banned' } } });
|
|
80
|
+
|
|
81
|
+
const qbReq = qb()
|
|
82
|
+
.find(() => new FieldConditionBuilder('status').notEquals('banned'))
|
|
83
|
+
.getQueryRequest();
|
|
84
|
+
|
|
85
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('{ field: { gt } }', async () => {
|
|
89
|
+
const { http, delegate, qb } = makeSetup();
|
|
90
|
+
|
|
91
|
+
await delegate.findMany({ where: { age: { gt: 18 } } });
|
|
92
|
+
|
|
93
|
+
const qbReq = qb()
|
|
94
|
+
.find(() => new FieldConditionBuilder('age').greaterThan(18))
|
|
95
|
+
.getQueryRequest();
|
|
96
|
+
|
|
97
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('{ field: { gte } }', async () => {
|
|
101
|
+
const { http, delegate, qb } = makeSetup();
|
|
102
|
+
|
|
103
|
+
await delegate.findMany({ where: { age: { gte: 18 } } });
|
|
104
|
+
|
|
105
|
+
const qbReq = qb()
|
|
106
|
+
.find(() => new FieldConditionBuilder('age').greaterThanOrEqual(18))
|
|
107
|
+
.getQueryRequest();
|
|
108
|
+
|
|
109
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('{ field: { lt } }', async () => {
|
|
113
|
+
const { http, delegate, qb } = makeSetup();
|
|
114
|
+
|
|
115
|
+
await delegate.findMany({ where: { score: { lt: 100 } } });
|
|
116
|
+
|
|
117
|
+
const qbReq = qb()
|
|
118
|
+
.find(() => new FieldConditionBuilder('score').lessThan(100))
|
|
119
|
+
.getQueryRequest();
|
|
120
|
+
|
|
121
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('{ field: { lte } }', async () => {
|
|
125
|
+
const { http, delegate, qb } = makeSetup();
|
|
126
|
+
|
|
127
|
+
await delegate.findMany({ where: { score: { lte: 100 } } });
|
|
128
|
+
|
|
129
|
+
const qbReq = qb()
|
|
130
|
+
.find(() => new FieldConditionBuilder('score').lessThanOrEqual(100))
|
|
131
|
+
.getQueryRequest();
|
|
132
|
+
|
|
133
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('{ field: { contains } }', async () => {
|
|
137
|
+
const { http, delegate, qb } = makeSetup('posts');
|
|
138
|
+
|
|
139
|
+
await delegate.findMany({ where: { title: { contains: 'typescript' } } });
|
|
140
|
+
|
|
141
|
+
const qbReq = qb()
|
|
142
|
+
.find(() => new FieldConditionBuilder('title').contains('typescript'))
|
|
143
|
+
.getQueryRequest();
|
|
144
|
+
|
|
145
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('{ field: { startsWith } }', async () => {
|
|
149
|
+
const { http, delegate, qb } = makeSetup('posts');
|
|
150
|
+
|
|
151
|
+
await delegate.findMany({ where: { slug: { startsWith: 'hello' } } });
|
|
152
|
+
|
|
153
|
+
const qbReq = qb()
|
|
154
|
+
.find(() => new FieldConditionBuilder('slug').startsWith('hello'))
|
|
155
|
+
.getQueryRequest();
|
|
156
|
+
|
|
157
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('{ field: { endsWith } }', async () => {
|
|
161
|
+
const { http, delegate, qb } = makeSetup('posts');
|
|
162
|
+
|
|
163
|
+
await delegate.findMany({ where: { slug: { endsWith: 'world' } } });
|
|
164
|
+
|
|
165
|
+
const qbReq = qb()
|
|
166
|
+
.find(() => new FieldConditionBuilder('slug').endsWith('world'))
|
|
167
|
+
.getQueryRequest();
|
|
168
|
+
|
|
169
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('{ field: { in } }', async () => {
|
|
173
|
+
const { http, delegate, qb } = makeSetup();
|
|
174
|
+
|
|
175
|
+
await delegate.findMany({ where: { status: { in: ['active', 'pending'] } } });
|
|
176
|
+
|
|
177
|
+
const qbReq = qb()
|
|
178
|
+
.find(() => new FieldConditionBuilder('status').in(['active', 'pending']))
|
|
179
|
+
.getQueryRequest();
|
|
180
|
+
|
|
181
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('{ field: { notIn } }', async () => {
|
|
185
|
+
const { http, delegate, qb } = makeSetup();
|
|
186
|
+
|
|
187
|
+
await delegate.findMany({ where: { status: { notIn: ['banned', 'deleted'] } } });
|
|
188
|
+
|
|
189
|
+
const qbReq = qb()
|
|
190
|
+
.find(() => new FieldConditionBuilder('status').notIn(['banned', 'deleted']))
|
|
191
|
+
.getQueryRequest();
|
|
192
|
+
|
|
193
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('{ field: { isNotNull: true } }', async () => {
|
|
197
|
+
const { http, delegate, qb } = makeSetup();
|
|
198
|
+
|
|
199
|
+
await delegate.findMany({ where: { deletedAt: { isNotNull: true } } });
|
|
200
|
+
|
|
201
|
+
const qbReq = qb()
|
|
202
|
+
.find(() => new FieldConditionBuilder('deletedAt').isNotNull())
|
|
203
|
+
.getQueryRequest();
|
|
204
|
+
|
|
205
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('{ field: { exists: true } }', async () => {
|
|
209
|
+
const { http, delegate, qb } = makeSetup();
|
|
210
|
+
|
|
211
|
+
await delegate.findMany({ where: { avatar: { exists: true } } });
|
|
212
|
+
|
|
213
|
+
const qbReq = qb()
|
|
214
|
+
.find(() => new FieldConditionBuilder('avatar').exists())
|
|
215
|
+
.getQueryRequest();
|
|
216
|
+
|
|
217
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('{ field: { exists: false } } → notExists', async () => {
|
|
221
|
+
const { http, delegate, qb } = makeSetup();
|
|
222
|
+
|
|
223
|
+
await delegate.findMany({ where: { avatar: { exists: false } } });
|
|
224
|
+
|
|
225
|
+
const qbReq = qb()
|
|
226
|
+
.find(() => new FieldConditionBuilder('avatar').notExists())
|
|
227
|
+
.getQueryRequest();
|
|
228
|
+
|
|
229
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('{ field: { between } }', async () => {
|
|
233
|
+
const { http, delegate, qb } = makeSetup();
|
|
234
|
+
|
|
235
|
+
await delegate.findMany({ where: { age: { between: { from: 18, to: 65 } } } });
|
|
236
|
+
|
|
237
|
+
const qbReq = qb()
|
|
238
|
+
.find(() => new FieldConditionBuilder('age').between(18, 65))
|
|
239
|
+
.getQueryRequest();
|
|
240
|
+
|
|
241
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('{ field: { inDataset } } (case-sensitive)', async () => {
|
|
245
|
+
const { http, delegate, qb } = makeSetup();
|
|
246
|
+
|
|
247
|
+
await delegate.findMany({ where: { role: { inDataset: ['Admin', 'SuperUser'] } } });
|
|
248
|
+
|
|
249
|
+
const qbReq = qb()
|
|
250
|
+
.find(() => new FieldConditionBuilder('role').inDataset(['Admin', 'SuperUser']))
|
|
251
|
+
.getQueryRequest();
|
|
252
|
+
|
|
253
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('{ field: { containsCaseInsensitive } }', async () => {
|
|
257
|
+
const { http, delegate, qb } = makeSetup('posts');
|
|
258
|
+
|
|
259
|
+
await delegate.findMany({ where: { title: { containsCaseInsensitive: 'rust' } } });
|
|
260
|
+
|
|
261
|
+
const qbReq = qb()
|
|
262
|
+
.find(() => new FieldConditionBuilder('title').includesCaseInsensitive('rust'))
|
|
263
|
+
.getQueryRequest();
|
|
264
|
+
|
|
265
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('{ field: { startsWithCaseInsensitive } }', async () => {
|
|
269
|
+
const { http, delegate, qb } = makeSetup('posts');
|
|
270
|
+
|
|
271
|
+
await delegate.findMany({ where: { slug: { startsWithCaseInsensitive: 'Hello' } } });
|
|
272
|
+
|
|
273
|
+
const qbReq = qb()
|
|
274
|
+
.find(() => new FieldConditionBuilder('slug').startsWithCaseInsensitive('Hello'))
|
|
275
|
+
.getQueryRequest();
|
|
276
|
+
|
|
277
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('{ field: { endsWithCaseInsensitive } }', async () => {
|
|
281
|
+
const { http, delegate, qb } = makeSetup('posts');
|
|
282
|
+
|
|
283
|
+
await delegate.findMany({ where: { slug: { endsWithCaseInsensitive: 'World' } } });
|
|
284
|
+
|
|
285
|
+
const qbReq = qb()
|
|
286
|
+
.find(() => new FieldConditionBuilder('slug').endsWithCaseInsensitive('World'))
|
|
287
|
+
.getQueryRequest();
|
|
288
|
+
|
|
289
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('{ field: { regex } }', async () => {
|
|
293
|
+
const { http, delegate, qb } = makeSetup();
|
|
294
|
+
|
|
295
|
+
await delegate.findMany({ where: { email: { regex: '^admin@' } } });
|
|
296
|
+
|
|
297
|
+
const qbReq = qb()
|
|
298
|
+
.find(() => new FieldConditionBuilder('email').regExpMatches('^admin@'))
|
|
299
|
+
.getQueryRequest();
|
|
300
|
+
|
|
301
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('{ field: { isLocalIp: true } }', async () => {
|
|
305
|
+
const { http, delegate, qb } = makeSetup('logs');
|
|
306
|
+
|
|
307
|
+
await delegate.findMany({ where: { ip: { isLocalIp: true } } });
|
|
308
|
+
|
|
309
|
+
const qbReq = qb()
|
|
310
|
+
.find(() => new FieldConditionBuilder('ip').isLocalIp())
|
|
311
|
+
.getQueryRequest();
|
|
312
|
+
|
|
313
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('{ field: { isExternalIp: true } }', async () => {
|
|
317
|
+
const { http, delegate, qb } = makeSetup('logs');
|
|
318
|
+
|
|
319
|
+
await delegate.findMany({ where: { ip: { isExternalIp: true } } });
|
|
320
|
+
|
|
321
|
+
const qbReq = qb()
|
|
322
|
+
.find(() => new FieldConditionBuilder('ip').isExternalIp())
|
|
323
|
+
.getQueryRequest();
|
|
324
|
+
|
|
325
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('{ field: { inCountry } }', async () => {
|
|
329
|
+
const { http, delegate, qb } = makeSetup('logs');
|
|
330
|
+
|
|
331
|
+
await delegate.findMany({ where: { ip: { inCountry: 'US' } } });
|
|
332
|
+
|
|
333
|
+
const qbReq = qb()
|
|
334
|
+
.find(() => new FieldConditionBuilder('ip').inCountry('US'))
|
|
335
|
+
.getQueryRequest();
|
|
336
|
+
|
|
337
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('{ field: { cidr } }', async () => {
|
|
341
|
+
const { http, delegate, qb } = makeSetup('logs');
|
|
342
|
+
|
|
343
|
+
await delegate.findMany({ where: { ip: { cidr: ['10.0.0.0/8', '192.168.0.0/16'] } } });
|
|
344
|
+
|
|
345
|
+
const qbReq = qb()
|
|
346
|
+
.find(() => new FieldConditionBuilder('ip').cidr(['10.0.0.0/8', '192.168.0.0/16']))
|
|
347
|
+
.getQueryRequest();
|
|
348
|
+
|
|
349
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('{ field: { keywords } }', async () => {
|
|
353
|
+
const { http, delegate, qb } = makeSetup('posts');
|
|
354
|
+
|
|
355
|
+
await delegate.findMany({ where: { content: { keywords: ['blockchain', 'celestia'] } } });
|
|
356
|
+
|
|
357
|
+
const qbReq = qb()
|
|
358
|
+
.find(() => new FieldConditionBuilder('content').keywords(['blockchain', 'celestia']))
|
|
359
|
+
.getQueryRequest();
|
|
360
|
+
|
|
361
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('{ field: { b64 } }', async () => {
|
|
365
|
+
const { http, delegate, qb } = makeSetup();
|
|
366
|
+
|
|
367
|
+
await delegate.findMany({ where: { payload: { b64: 'aGVsbG8=' } } });
|
|
368
|
+
|
|
369
|
+
const qbReq = qb()
|
|
370
|
+
.find(() => new FieldConditionBuilder('payload').b64('aGVsbG8='))
|
|
371
|
+
.getQueryRequest();
|
|
372
|
+
|
|
373
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('{ field: null } → isNull', async () => {
|
|
377
|
+
const { http, delegate, qb } = makeSetup();
|
|
378
|
+
|
|
379
|
+
await delegate.findMany({ where: { deletedAt: null } });
|
|
380
|
+
|
|
381
|
+
const qbReq = qb()
|
|
382
|
+
.find(() => new FieldConditionBuilder('deletedAt').isNull())
|
|
383
|
+
.getQueryRequest();
|
|
384
|
+
|
|
385
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// ── findMany — logical operators ──────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
describe('findMany logical operators', () => {
|
|
392
|
+
|
|
393
|
+
test('multiple top-level fields → implicit AND', async () => {
|
|
394
|
+
const { http, delegate, qb } = makeSetup();
|
|
395
|
+
|
|
396
|
+
await delegate.findMany({ where: { active: true, role: 'admin' } });
|
|
397
|
+
|
|
398
|
+
const qbReq = qb()
|
|
399
|
+
.find(() => LogicalOperator.And([
|
|
400
|
+
new FieldConditionBuilder('active').equals(true),
|
|
401
|
+
new FieldConditionBuilder('role').equals('admin'),
|
|
402
|
+
]))
|
|
403
|
+
.getQueryRequest();
|
|
404
|
+
|
|
405
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('AND: [...]', async () => {
|
|
409
|
+
const { http, delegate, qb } = makeSetup();
|
|
410
|
+
|
|
411
|
+
await delegate.findMany({
|
|
412
|
+
where: { AND: [{ age: { gte: 18 } }, { age: { lt: 65 } }] },
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const qbReq = qb()
|
|
416
|
+
.find(() => LogicalOperator.And([
|
|
417
|
+
new FieldConditionBuilder('age').greaterThanOrEqual(18),
|
|
418
|
+
new FieldConditionBuilder('age').lessThan(65),
|
|
419
|
+
]))
|
|
420
|
+
.getQueryRequest();
|
|
421
|
+
|
|
422
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('OR: [...]', async () => {
|
|
426
|
+
const { http, delegate, qb } = makeSetup();
|
|
427
|
+
|
|
428
|
+
await delegate.findMany({
|
|
429
|
+
where: { OR: [{ role: 'admin' }, { role: 'superuser' }] },
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const qbReq = qb()
|
|
433
|
+
.find(() => LogicalOperator.Or([
|
|
434
|
+
new FieldConditionBuilder('role').equals('admin'),
|
|
435
|
+
new FieldConditionBuilder('role').equals('superuser'),
|
|
436
|
+
]))
|
|
437
|
+
.getQueryRequest();
|
|
438
|
+
|
|
439
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('NOT: [...]', async () => {
|
|
443
|
+
const { http, delegate, qb } = makeSetup();
|
|
444
|
+
|
|
445
|
+
await delegate.findMany({
|
|
446
|
+
where: { NOT: [{ status: 'banned' }] },
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const qbReq = qb()
|
|
450
|
+
.find(() => LogicalOperator.Not([
|
|
451
|
+
new FieldConditionBuilder('status').equals('banned'),
|
|
452
|
+
]))
|
|
453
|
+
.getQueryRequest();
|
|
454
|
+
|
|
455
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// ── findMany — pagination + sorting ──────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
describe('findMany pagination and sorting', () => {
|
|
462
|
+
|
|
463
|
+
test('take + skip → limit + offset', async () => {
|
|
464
|
+
const { http, delegate, qb } = makeSetup('posts');
|
|
465
|
+
|
|
466
|
+
await delegate.findMany({ take: 20, skip: 40 });
|
|
467
|
+
|
|
468
|
+
const qbReq = qb().limit(20).offset(40).getQueryRequest();
|
|
469
|
+
|
|
470
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test('orderBy asc', async () => {
|
|
474
|
+
const { http, delegate, qb } = makeSetup('posts');
|
|
475
|
+
|
|
476
|
+
await delegate.findMany({ orderBy: { createdAt: 'asc' } });
|
|
477
|
+
|
|
478
|
+
const qbReq = qb().sortBy('createdAt', 'asc').getQueryRequest();
|
|
479
|
+
|
|
480
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test('orderBy desc', async () => {
|
|
484
|
+
const { http, delegate, qb } = makeSetup('posts');
|
|
485
|
+
|
|
486
|
+
await delegate.findMany({ orderBy: { createdAt: 'desc' } });
|
|
487
|
+
|
|
488
|
+
const qbReq = qb().sortBy('createdAt', 'desc').getQueryRequest();
|
|
489
|
+
|
|
490
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test('where + take + skip + orderBy combined', async () => {
|
|
494
|
+
const { http, delegate, qb } = makeSetup();
|
|
495
|
+
|
|
496
|
+
await delegate.findMany({
|
|
497
|
+
where: { active: true },
|
|
498
|
+
orderBy: { name: 'asc' },
|
|
499
|
+
take: 10,
|
|
500
|
+
skip: 5,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const qbReq = qb()
|
|
504
|
+
.find(() => new FieldConditionBuilder('active').equals(true))
|
|
505
|
+
.sortBy('name', 'asc')
|
|
506
|
+
.limit(10)
|
|
507
|
+
.offset(5)
|
|
508
|
+
.getQueryRequest();
|
|
509
|
+
|
|
510
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// ── findMany — select ─────────────────────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
describe('findMany select', () => {
|
|
517
|
+
|
|
518
|
+
test('select specific fields', async () => {
|
|
519
|
+
const { http, delegate, qb } = makeSetup();
|
|
520
|
+
|
|
521
|
+
await delegate.findMany({ select: { id: true, name: true, email: true } });
|
|
522
|
+
|
|
523
|
+
const qbReq = qb().selectFields(['id', 'name', 'email']).getQueryRequest();
|
|
524
|
+
|
|
525
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// ── findFirst ─────────────────────────────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
describe('findFirst', () => {
|
|
532
|
+
|
|
533
|
+
test('findFirst = findMany with take: 1', async () => {
|
|
534
|
+
const { http, delegate, qb } = makeSetup();
|
|
535
|
+
|
|
536
|
+
await delegate.findFirst({ where: { email: 'alice@example.com' } });
|
|
537
|
+
|
|
538
|
+
const qbReq = qb()
|
|
539
|
+
.find(() => new FieldConditionBuilder('email').equals('alice@example.com'))
|
|
540
|
+
.limit(1)
|
|
541
|
+
.getQueryRequest();
|
|
542
|
+
|
|
543
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// ── findUnique ────────────────────────────────────────────────────────────────
|
|
548
|
+
|
|
549
|
+
describe('findUnique', () => {
|
|
550
|
+
|
|
551
|
+
test('findUnique = findFirst (take: 1)', async () => {
|
|
552
|
+
const { http, delegate, qb } = makeSetup();
|
|
553
|
+
|
|
554
|
+
await delegate.findUnique({ where: { id: 'user-123' } });
|
|
555
|
+
|
|
556
|
+
const qbReq = qb()
|
|
557
|
+
.find(() => new FieldConditionBuilder('id').equals('user-123'))
|
|
558
|
+
.limit(1)
|
|
559
|
+
.getQueryRequest();
|
|
560
|
+
|
|
561
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// ── excludeDeleted ────────────────────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
describe('excludeDeleted', () => {
|
|
568
|
+
|
|
569
|
+
test('true → ANDs notExists("deletedAt") with where conditions', async () => {
|
|
570
|
+
const { http, delegate, qb } = makeSetup();
|
|
571
|
+
|
|
572
|
+
await delegate.findMany({ where: { active: true }, excludeDeleted: true });
|
|
573
|
+
|
|
574
|
+
const qbReq = qb()
|
|
575
|
+
.find(() => LogicalOperator.And([
|
|
576
|
+
new FieldConditionBuilder('active').equals(true),
|
|
577
|
+
new FieldConditionBuilder('deletedAt').notExists(),
|
|
578
|
+
]))
|
|
579
|
+
.getQueryRequest();
|
|
580
|
+
|
|
581
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test('custom string key → notExists(key)', async () => {
|
|
585
|
+
const { http, delegate, qb } = makeSetup();
|
|
586
|
+
|
|
587
|
+
await delegate.findMany({ where: { active: true }, excludeDeleted: 'removedAt' });
|
|
588
|
+
|
|
589
|
+
const qbReq = qb()
|
|
590
|
+
.find(() => LogicalOperator.And([
|
|
591
|
+
new FieldConditionBuilder('active').equals(true),
|
|
592
|
+
new FieldConditionBuilder('removedAt').notExists(),
|
|
593
|
+
]))
|
|
594
|
+
.getQueryRequest();
|
|
595
|
+
|
|
596
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('with no where — only notExists condition', async () => {
|
|
600
|
+
const { http, delegate, qb } = makeSetup();
|
|
601
|
+
|
|
602
|
+
await delegate.findMany({ excludeDeleted: true });
|
|
603
|
+
|
|
604
|
+
const qbReq = qb()
|
|
605
|
+
.find(() => new FieldConditionBuilder('deletedAt').notExists())
|
|
606
|
+
.getQueryRequest();
|
|
607
|
+
|
|
608
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('false / undefined → no deleted filter applied', async () => {
|
|
612
|
+
const { http, delegate, qb } = makeSetup();
|
|
613
|
+
|
|
614
|
+
await delegate.findMany({ where: { active: true }, excludeDeleted: false });
|
|
615
|
+
|
|
616
|
+
const qbReq = qb()
|
|
617
|
+
.find(() => new FieldConditionBuilder('active').equals(true))
|
|
618
|
+
.getQueryRequest();
|
|
619
|
+
|
|
620
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test('propagates to findFirst via take: 1', async () => {
|
|
624
|
+
const { http, delegate, qb } = makeSetup();
|
|
625
|
+
|
|
626
|
+
await delegate.findFirst({ where: { email: 'a@b.com' }, excludeDeleted: true });
|
|
627
|
+
|
|
628
|
+
const qbReq = qb()
|
|
629
|
+
.find(() => LogicalOperator.And([
|
|
630
|
+
new FieldConditionBuilder('email').equals('a@b.com'),
|
|
631
|
+
new FieldConditionBuilder('deletedAt').notExists(),
|
|
632
|
+
]))
|
|
633
|
+
.limit(1)
|
|
634
|
+
.getQueryRequest();
|
|
635
|
+
|
|
636
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test('propagates to findUnique via findFirst', async () => {
|
|
640
|
+
const { http, delegate, qb } = makeSetup();
|
|
641
|
+
|
|
642
|
+
await delegate.findUnique({ where: { id: 'user-1' }, excludeDeleted: true } as any);
|
|
643
|
+
|
|
644
|
+
const qbReq = qb()
|
|
645
|
+
.find(() => LogicalOperator.And([
|
|
646
|
+
new FieldConditionBuilder('id').equals('user-1'),
|
|
647
|
+
new FieldConditionBuilder('deletedAt').notExists(),
|
|
648
|
+
]))
|
|
649
|
+
.limit(1)
|
|
650
|
+
.getQueryRequest();
|
|
651
|
+
|
|
652
|
+
expect(http.lastRequest).toEqual(qbReq);
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// ── updateDocument ────────────────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
describe('updateDocument', () => {
|
|
659
|
+
function makeUpdateSetup(existingDoc: Record<string, any>) {
|
|
660
|
+
const storeMock = jest.fn().mockResolvedValue({ id: 'tx-1' });
|
|
661
|
+
const client = {
|
|
662
|
+
queryBuilder: () => new QueryBuilder(
|
|
663
|
+
{
|
|
664
|
+
post: jest.fn().mockResolvedValue({
|
|
665
|
+
status: 200,
|
|
666
|
+
data: { records: [existingDoc], total: 1, page: 0, limit: 1 },
|
|
667
|
+
}),
|
|
668
|
+
} as any,
|
|
669
|
+
URL,
|
|
670
|
+
APP
|
|
671
|
+
),
|
|
672
|
+
store: storeMock,
|
|
673
|
+
} as unknown as OnDBClient;
|
|
674
|
+
return { delegate: new ModelDelegate(client, COLLECTION), storeMock };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
test('merges patch on top of existing document', async () => {
|
|
678
|
+
const existing = { id: 'user-1', name: 'Alice', age: 30, active: true };
|
|
679
|
+
const { delegate, storeMock } = makeUpdateSetup(existing);
|
|
680
|
+
|
|
681
|
+
await delegate.updateDocument({ id: 'user-1' }, { name: 'Bob' });
|
|
682
|
+
|
|
683
|
+
expect(storeMock).toHaveBeenCalledWith({
|
|
684
|
+
collection: COLLECTION,
|
|
685
|
+
data: [{ id: 'user-1', name: 'Bob', age: 30, active: true }],
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test('patch fields overwrite existing, non-patched fields are preserved', async () => {
|
|
690
|
+
const existing = { id: 'user-1', name: 'Alice', email: 'alice@example.com', role: 'user' };
|
|
691
|
+
const { delegate, storeMock } = makeUpdateSetup(existing);
|
|
692
|
+
|
|
693
|
+
await delegate.updateDocument({ id: 'user-1' }, { role: 'admin' });
|
|
694
|
+
|
|
695
|
+
expect(storeMock).toHaveBeenCalledWith({
|
|
696
|
+
collection: COLLECTION,
|
|
697
|
+
data: [{ id: 'user-1', name: 'Alice', email: 'alice@example.com', role: 'admin' }],
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test('throws if no document matches identifierFind', async () => {
|
|
702
|
+
const storeMock = jest.fn();
|
|
703
|
+
const client = {
|
|
704
|
+
queryBuilder: () => new QueryBuilder(
|
|
705
|
+
{
|
|
706
|
+
post: jest.fn().mockResolvedValue({
|
|
707
|
+
status: 200,
|
|
708
|
+
data: { records: [], total: 0, page: 0, limit: 1 },
|
|
709
|
+
}),
|
|
710
|
+
} as any,
|
|
711
|
+
URL,
|
|
712
|
+
APP
|
|
713
|
+
),
|
|
714
|
+
store: storeMock,
|
|
715
|
+
} as unknown as OnDBClient;
|
|
716
|
+
|
|
717
|
+
const delegate = new ModelDelegate(client, COLLECTION);
|
|
718
|
+
await expect(delegate.updateDocument({ id: 'ghost' }, { name: 'X' }))
|
|
719
|
+
.rejects.toThrow('updateDocument: no document found matching the identifier');
|
|
720
|
+
expect(storeMock).not.toHaveBeenCalled();
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// ── deleteDocument ────────────────────────────────────────────────────────────
|
|
725
|
+
|
|
726
|
+
describe('deleteDocument', () => {
|
|
727
|
+
function makeDeleteSetup(existingDoc: Record<string, any>) {
|
|
728
|
+
const storeMock = jest.fn().mockResolvedValue({ id: 'tx-1' });
|
|
729
|
+
const client = {
|
|
730
|
+
queryBuilder: () => new QueryBuilder(
|
|
731
|
+
{
|
|
732
|
+
post: jest.fn().mockResolvedValue({
|
|
733
|
+
status: 200,
|
|
734
|
+
data: { records: [existingDoc], total: 1, page: 0, limit: 1 },
|
|
735
|
+
}),
|
|
736
|
+
} as any,
|
|
737
|
+
URL,
|
|
738
|
+
APP
|
|
739
|
+
),
|
|
740
|
+
store: storeMock,
|
|
741
|
+
} as unknown as OnDBClient;
|
|
742
|
+
return { delegate: new ModelDelegate(client, COLLECTION), storeMock };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
test('stores document with deletedAt set to a timestamp', async () => {
|
|
746
|
+
const existing = { id: 'user-1', name: 'Alice', active: true };
|
|
747
|
+
const { delegate, storeMock } = makeDeleteSetup(existing);
|
|
748
|
+
|
|
749
|
+
const before = Date.now();
|
|
750
|
+
await delegate.deleteDocument({ id: 'user-1' });
|
|
751
|
+
const after = Date.now();
|
|
752
|
+
|
|
753
|
+
expect(storeMock).toHaveBeenCalledTimes(1);
|
|
754
|
+
const stored = storeMock.mock.calls[0][0].data[0];
|
|
755
|
+
expect(stored.id).toBe('user-1');
|
|
756
|
+
expect(stored.name).toBe('Alice');
|
|
757
|
+
expect(stored.active).toBe(true);
|
|
758
|
+
expect(stored.deletedAt).toBeGreaterThanOrEqual(before);
|
|
759
|
+
expect(stored.deletedAt).toBeLessThanOrEqual(after);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
test('optional patch is merged after deletedAt', async () => {
|
|
763
|
+
const existing = { id: 'user-1', name: 'Alice', active: true };
|
|
764
|
+
const { delegate, storeMock } = makeDeleteSetup(existing);
|
|
765
|
+
|
|
766
|
+
await delegate.deleteDocument({ id: 'user-1' }, { active: false, reason: 'violation' });
|
|
767
|
+
|
|
768
|
+
const stored = storeMock.mock.calls[0][0].data[0];
|
|
769
|
+
expect(stored.active).toBe(false);
|
|
770
|
+
expect(stored.reason).toBe('violation');
|
|
771
|
+
expect(stored.deletedAt).toBeDefined();
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test('throws if no document matches identifierFind', async () => {
|
|
775
|
+
const storeMock = jest.fn();
|
|
776
|
+
const client = {
|
|
777
|
+
queryBuilder: () => new QueryBuilder(
|
|
778
|
+
{
|
|
779
|
+
post: jest.fn().mockResolvedValue({
|
|
780
|
+
status: 200,
|
|
781
|
+
data: { records: [], total: 0, page: 0, limit: 1 },
|
|
782
|
+
}),
|
|
783
|
+
} as any,
|
|
784
|
+
URL,
|
|
785
|
+
APP
|
|
786
|
+
),
|
|
787
|
+
store: storeMock,
|
|
788
|
+
} as unknown as OnDBClient;
|
|
789
|
+
|
|
790
|
+
const delegate = new ModelDelegate(client, COLLECTION);
|
|
791
|
+
await expect(delegate.deleteDocument({ id: 'ghost' }))
|
|
792
|
+
.rejects.toThrow('deleteDocument: no document found matching the identifier');
|
|
793
|
+
expect(storeMock).not.toHaveBeenCalled();
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
// ── count ─────────────────────────────────────────────────────────────────────
|
|
798
|
+
|
|
799
|
+
describe('count', () => {
|
|
800
|
+
|
|
801
|
+
test('count({ where }) sends same request as qb.count()', async () => {
|
|
802
|
+
// Run via ModelDelegate
|
|
803
|
+
const { http: http1, delegate } = makeSetup();
|
|
804
|
+
await delegate.count({ where: { active: true } });
|
|
805
|
+
const prismaReq = http1.lastRequest;
|
|
806
|
+
|
|
807
|
+
// Run via raw QueryBuilder
|
|
808
|
+
const http2 = new CapturingHttpClient();
|
|
809
|
+
const refQb = new QueryBuilder(http2, URL, APP)
|
|
810
|
+
.collection(COLLECTION)
|
|
811
|
+
.find(() => new FieldConditionBuilder('active').equals(true));
|
|
812
|
+
await refQb.count();
|
|
813
|
+
const qbReq = http2.lastRequest;
|
|
814
|
+
|
|
815
|
+
expect(prismaReq).toEqual(qbReq);
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// ── aggregate ─────────────────────────────────────────────────────────────────
|
|
820
|
+
|
|
821
|
+
describe('aggregate', () => {
|
|
822
|
+
|
|
823
|
+
test('_count: true matches qb.runAggregate', async () => {
|
|
824
|
+
const { http: http1, delegate } = makeSetup();
|
|
825
|
+
await delegate.aggregate({ where: { active: true }, _count: true });
|
|
826
|
+
const prismaReq = http1.lastRequest;
|
|
827
|
+
|
|
828
|
+
const http2 = new CapturingHttpClient();
|
|
829
|
+
await new QueryBuilder(http2, URL, APP)
|
|
830
|
+
.collection(COLLECTION)
|
|
831
|
+
.find(() => new FieldConditionBuilder('active').equals(true))
|
|
832
|
+
.runAggregate({ _count: { '$count': '*' } });
|
|
833
|
+
const qbReq = http2.lastRequest;
|
|
834
|
+
|
|
835
|
+
expect(prismaReq).toEqual(qbReq);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test('_sum + _avg matches qb.runAggregate', async () => {
|
|
839
|
+
const { http: http1, delegate } = makeSetup();
|
|
840
|
+
await delegate.aggregate({
|
|
841
|
+
where: { active: true },
|
|
842
|
+
_sum: { balance: true },
|
|
843
|
+
_avg: { age: true },
|
|
844
|
+
});
|
|
845
|
+
const prismaReq = http1.lastRequest;
|
|
846
|
+
|
|
847
|
+
const http2 = new CapturingHttpClient();
|
|
848
|
+
await new QueryBuilder(http2, URL, APP)
|
|
849
|
+
.collection(COLLECTION)
|
|
850
|
+
.find(() => new FieldConditionBuilder('active').equals(true))
|
|
851
|
+
.runAggregate({
|
|
852
|
+
_sum_balance: { '$sum': 'balance' },
|
|
853
|
+
_avg_age: { '$avg': 'age' },
|
|
854
|
+
});
|
|
855
|
+
const qbReq = http2.lastRequest;
|
|
856
|
+
|
|
857
|
+
expect(prismaReq).toEqual(qbReq);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test('_count + _sum + _avg + _min + _max all together', async () => {
|
|
861
|
+
const { http: http1, delegate } = makeSetup();
|
|
862
|
+
await delegate.aggregate({
|
|
863
|
+
_count: true,
|
|
864
|
+
_sum: { balance: true },
|
|
865
|
+
_avg: { age: true },
|
|
866
|
+
_min: { age: true },
|
|
867
|
+
_max: { balance: true },
|
|
868
|
+
});
|
|
869
|
+
const prismaReq = http1.lastRequest;
|
|
870
|
+
|
|
871
|
+
const http2 = new CapturingHttpClient();
|
|
872
|
+
await new QueryBuilder(http2, URL, APP)
|
|
873
|
+
.collection(COLLECTION)
|
|
874
|
+
.runAggregate({
|
|
875
|
+
_count: { '$count': '*' },
|
|
876
|
+
_sum_balance: { '$sum': 'balance' },
|
|
877
|
+
_avg_age: { '$avg': 'age' },
|
|
878
|
+
_min_age: { '$min': 'age' },
|
|
879
|
+
_max_balance: { '$max': 'balance' },
|
|
880
|
+
});
|
|
881
|
+
const qbReq = http2.lastRequest;
|
|
882
|
+
|
|
883
|
+
expect(prismaReq).toEqual(qbReq);
|
|
884
|
+
});
|
|
885
|
+
});
|