@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
package/src/model.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { OnDBClient } from './client';
|
|
2
|
+
import { FieldConditionBuilder, LogicalOperator, QueryBuilder } from './query-sdk';
|
|
3
|
+
import { AggregateSpec, SelectionMap } from './query-sdk';
|
|
4
|
+
import { StoreResponse } from './types';
|
|
5
|
+
|
|
6
|
+
// ── Where input types ─────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export type FieldFilter = {
|
|
9
|
+
// ── Equality ─────────────────────────────────────────────────────────────
|
|
10
|
+
equals?: any;
|
|
11
|
+
not?: any;
|
|
12
|
+
in?: any[];
|
|
13
|
+
notIn?: any[];
|
|
14
|
+
/** Case-sensitive membership check (unlike `in` which is case-insensitive). */
|
|
15
|
+
inDataset?: string[];
|
|
16
|
+
|
|
17
|
+
// ── Numeric / comparison ─────────────────────────────────────────────────
|
|
18
|
+
lt?: any;
|
|
19
|
+
lte?: any;
|
|
20
|
+
gt?: any;
|
|
21
|
+
gte?: any;
|
|
22
|
+
/** Exclusive range: from < value < to. */
|
|
23
|
+
between?: { from: any; to: any };
|
|
24
|
+
|
|
25
|
+
// ── String ───────────────────────────────────────────────────────────────
|
|
26
|
+
contains?: string;
|
|
27
|
+
startsWith?: string;
|
|
28
|
+
endsWith?: string;
|
|
29
|
+
containsCaseInsensitive?: string;
|
|
30
|
+
startsWithCaseInsensitive?: string;
|
|
31
|
+
endsWithCaseInsensitive?: string;
|
|
32
|
+
regex?: string;
|
|
33
|
+
|
|
34
|
+
// ── Presence ─────────────────────────────────────────────────────────────
|
|
35
|
+
isNotNull?: true;
|
|
36
|
+
exists?: boolean;
|
|
37
|
+
|
|
38
|
+
// ── IP ───────────────────────────────────────────────────────────────────
|
|
39
|
+
isLocalIp?: true;
|
|
40
|
+
isExternalIp?: true;
|
|
41
|
+
inCountry?: string;
|
|
42
|
+
/** CIDR range check. Always uses "$cidr" alias to avoid Scepter stack overflow. */
|
|
43
|
+
cidr?: string | string[];
|
|
44
|
+
|
|
45
|
+
// ── Misc ─────────────────────────────────────────────────────────────────
|
|
46
|
+
/** Array of keywords — matches if the field contains ANY of them (case-insensitive substring). */
|
|
47
|
+
keywords?: string[];
|
|
48
|
+
b64?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Prisma-style where clause. Multiple top-level fields are implicitly ANDed. */
|
|
52
|
+
export type WhereInput<T = any> = {
|
|
53
|
+
[K in keyof T]?: T[K] | FieldFilter;
|
|
54
|
+
} & {
|
|
55
|
+
AND?: WhereInput<T>[];
|
|
56
|
+
OR?: WhereInput<T>[];
|
|
57
|
+
NOT?: WhereInput<T>[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type OrderByInput<T = any> = {
|
|
61
|
+
[K in keyof T]?: 'asc' | 'desc';
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type SelectInput<T = any> = {
|
|
65
|
+
[K in keyof T]?: boolean;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ── Arg interfaces ────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
export interface FindManyArgs<T = any> {
|
|
71
|
+
where?: WhereInput<T>;
|
|
72
|
+
select?: SelectInput<T>;
|
|
73
|
+
orderBy?: OrderByInput<T>;
|
|
74
|
+
take?: number;
|
|
75
|
+
skip?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Exclude soft-deleted records.
|
|
78
|
+
* - `true` → filters out records where `deletedAt` exists
|
|
79
|
+
* - `string` → filters out records where the given field exists (custom delete key)
|
|
80
|
+
*/
|
|
81
|
+
excludeDeleted?: boolean | string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type FindFirstArgs<T = any> = Omit<FindManyArgs<T>, 'take'>;
|
|
85
|
+
|
|
86
|
+
export interface FindUniqueArgs<T = any> {
|
|
87
|
+
where: WhereInput<T>;
|
|
88
|
+
select?: SelectInput<T>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CreateArgs<T = any> {
|
|
92
|
+
data: Partial<T> & Record<string, any>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface CreateManyArgs<T = any> {
|
|
96
|
+
data: Array<Partial<T> & Record<string, any>>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface CountArgs<T = any> {
|
|
100
|
+
where?: WhereInput<T>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface AggregateArgs<T = any> {
|
|
104
|
+
where?: WhereInput<T>;
|
|
105
|
+
/** Count all records */
|
|
106
|
+
_count?: boolean;
|
|
107
|
+
/** Sum numeric fields: { _sum: { age: true } } */
|
|
108
|
+
_sum?: { [K in keyof T]?: boolean };
|
|
109
|
+
/** Average numeric fields */
|
|
110
|
+
_avg?: { [K in keyof T]?: boolean };
|
|
111
|
+
/** Minimum value per field */
|
|
112
|
+
_min?: { [K in keyof T]?: boolean };
|
|
113
|
+
/** Maximum value per field */
|
|
114
|
+
_max?: { [K in keyof T]?: boolean };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Where → LogicalOperator converter ────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
const OPERATOR_KEYS = new Set([
|
|
120
|
+
'equals', 'not', 'in', 'notIn', 'inDataset',
|
|
121
|
+
'lt', 'lte', 'gt', 'gte', 'between',
|
|
122
|
+
'contains', 'startsWith', 'endsWith',
|
|
123
|
+
'containsCaseInsensitive', 'startsWithCaseInsensitive', 'endsWithCaseInsensitive',
|
|
124
|
+
'regex',
|
|
125
|
+
'isNotNull', 'exists',
|
|
126
|
+
'isLocalIp', 'isExternalIp', 'inCountry', 'cidr',
|
|
127
|
+
'keywords', 'b64',
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
function buildConditions(where: WhereInput): LogicalOperator[] {
|
|
131
|
+
const conditions: LogicalOperator[] = [];
|
|
132
|
+
|
|
133
|
+
for (const [key, value] of Object.entries(where)) {
|
|
134
|
+
if (key === 'AND' && Array.isArray(value)) {
|
|
135
|
+
const inner = (value as WhereInput[]).flatMap(buildConditions);
|
|
136
|
+
if (inner.length > 0) conditions.push(LogicalOperator.And(inner));
|
|
137
|
+
|
|
138
|
+
} else if (key === 'OR' && Array.isArray(value)) {
|
|
139
|
+
const inner = (value as WhereInput[]).flatMap(buildConditions);
|
|
140
|
+
if (inner.length > 0) conditions.push(LogicalOperator.Or(inner));
|
|
141
|
+
|
|
142
|
+
} else if (key === 'NOT' && Array.isArray(value)) {
|
|
143
|
+
const inner = (value as WhereInput[]).flatMap(buildConditions);
|
|
144
|
+
if (inner.length > 0) conditions.push(LogicalOperator.Not(inner));
|
|
145
|
+
|
|
146
|
+
} else if (value === null || value === undefined) {
|
|
147
|
+
conditions.push(new FieldConditionBuilder(key).isNull());
|
|
148
|
+
|
|
149
|
+
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
|
150
|
+
const isOpObject = Object.keys(value as object).some(k => OPERATOR_KEYS.has(k));
|
|
151
|
+
|
|
152
|
+
if (isOpObject) {
|
|
153
|
+
const f = value as FieldFilter;
|
|
154
|
+
const fcb = (k = key) => new FieldConditionBuilder(k);
|
|
155
|
+
|
|
156
|
+
// ── Equality ─────────────────────────────────────────────────
|
|
157
|
+
if (f.equals !== undefined) conditions.push(fcb().equals(f.equals));
|
|
158
|
+
if (f.not !== undefined) conditions.push(fcb().notEquals(f.not));
|
|
159
|
+
if (f.in !== undefined) conditions.push(fcb().in(f.in));
|
|
160
|
+
if (f.notIn !== undefined) conditions.push(fcb().notIn(f.notIn));
|
|
161
|
+
if (f.inDataset !== undefined) conditions.push(fcb().inDataset(f.inDataset));
|
|
162
|
+
|
|
163
|
+
// ── Numeric / comparison ─────────────────────────────────────
|
|
164
|
+
if (f.lt !== undefined) conditions.push(fcb().lessThan(f.lt));
|
|
165
|
+
if (f.lte !== undefined) conditions.push(fcb().lessThanOrEqual(f.lte));
|
|
166
|
+
if (f.gt !== undefined) conditions.push(fcb().greaterThan(f.gt));
|
|
167
|
+
if (f.gte !== undefined) conditions.push(fcb().greaterThanOrEqual(f.gte));
|
|
168
|
+
if (f.between !== undefined) conditions.push(fcb().between(f.between.from, f.between.to));
|
|
169
|
+
|
|
170
|
+
// ── String ───────────────────────────────────────────────────
|
|
171
|
+
if (f.contains !== undefined) conditions.push(fcb().contains(f.contains));
|
|
172
|
+
if (f.startsWith !== undefined) conditions.push(fcb().startsWith(f.startsWith));
|
|
173
|
+
if (f.endsWith !== undefined) conditions.push(fcb().endsWith(f.endsWith));
|
|
174
|
+
if (f.containsCaseInsensitive !== undefined) conditions.push(fcb().includesCaseInsensitive(f.containsCaseInsensitive));
|
|
175
|
+
if (f.startsWithCaseInsensitive !== undefined) conditions.push(fcb().startsWithCaseInsensitive(f.startsWithCaseInsensitive));
|
|
176
|
+
if (f.endsWithCaseInsensitive !== undefined) conditions.push(fcb().endsWithCaseInsensitive(f.endsWithCaseInsensitive));
|
|
177
|
+
if (f.regex !== undefined) conditions.push(fcb().regExpMatches(f.regex));
|
|
178
|
+
|
|
179
|
+
// ── Presence ─────────────────────────────────────────────────
|
|
180
|
+
if (f.isNotNull) conditions.push(fcb().isNotNull());
|
|
181
|
+
if (f.exists === true) conditions.push(fcb().exists());
|
|
182
|
+
if (f.exists === false) conditions.push(fcb().notExists());
|
|
183
|
+
|
|
184
|
+
// ── IP ───────────────────────────────────────────────────────
|
|
185
|
+
if (f.isLocalIp) conditions.push(fcb().isLocalIp());
|
|
186
|
+
if (f.isExternalIp) conditions.push(fcb().isExternalIp());
|
|
187
|
+
if (f.inCountry !== undefined) conditions.push(fcb().inCountry(f.inCountry));
|
|
188
|
+
if (f.cidr !== undefined) conditions.push(fcb().cidr(f.cidr));
|
|
189
|
+
|
|
190
|
+
// ── Misc ─────────────────────────────────────────────────────
|
|
191
|
+
if (f.keywords !== undefined) conditions.push(fcb().keywords(f.keywords));
|
|
192
|
+
if (f.b64 !== undefined) conditions.push(fcb().b64(f.b64));
|
|
193
|
+
} else {
|
|
194
|
+
// Shorthand object equality: { metadata: { role: 'admin' } }
|
|
195
|
+
conditions.push(new FieldConditionBuilder(key).equals(value));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
} else {
|
|
199
|
+
// Primitive shorthand: { name: 'Alice', active: true }
|
|
200
|
+
conditions.push(new FieldConditionBuilder(key).equals(value));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return conditions;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function applyWhere(qb: QueryBuilder, where?: WhereInput, excludeDeletedKey?: string): QueryBuilder {
|
|
208
|
+
const conditions: LogicalOperator[] = [];
|
|
209
|
+
|
|
210
|
+
if (where && Object.keys(where).length > 0) {
|
|
211
|
+
conditions.push(...buildConditions(where));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (excludeDeletedKey) {
|
|
215
|
+
conditions.push(new FieldConditionBuilder(excludeDeletedKey).notExists());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (conditions.length === 0) return qb;
|
|
219
|
+
const op = conditions.length === 1 ? conditions[0] : LogicalOperator.And(conditions);
|
|
220
|
+
return qb.find(() => op);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function applySelect<T>(qb: QueryBuilder, select?: SelectInput<T>): QueryBuilder {
|
|
224
|
+
if (!select) return qb;
|
|
225
|
+
const fields = Object.entries(select)
|
|
226
|
+
.filter(([, v]) => v === true)
|
|
227
|
+
.map(([k]) => k);
|
|
228
|
+
return fields.length > 0 ? qb.selectFields(fields) : qb;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildAggregateSpec<T>(args: AggregateArgs<T>): AggregateSpec {
|
|
232
|
+
const spec: AggregateSpec = {};
|
|
233
|
+
|
|
234
|
+
if (args._count) {
|
|
235
|
+
spec['_count'] = { '$count': '*' };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const numericOps: Array<[keyof AggregateArgs, string]> = [
|
|
239
|
+
['_sum', '$sum'],
|
|
240
|
+
['_avg', '$avg'],
|
|
241
|
+
['_min', '$min'],
|
|
242
|
+
['_max', '$max'],
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
for (const [argKey, scepterOp] of numericOps) {
|
|
246
|
+
const fields = args[argKey as keyof typeof args] as Record<string, boolean> | undefined;
|
|
247
|
+
if (!fields) continue;
|
|
248
|
+
for (const [field, enabled] of Object.entries(fields)) {
|
|
249
|
+
if (enabled) spec[`${argKey}_${field}`] = { [scepterOp]: field } as any;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return spec;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── ModelDelegate ─────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
export class ModelDelegate<T extends Record<string, any> = Record<string, any>> {
|
|
259
|
+
constructor(
|
|
260
|
+
private readonly client: OnDBClient,
|
|
261
|
+
private readonly collection: string
|
|
262
|
+
) {}
|
|
263
|
+
|
|
264
|
+
private qb(): QueryBuilder {
|
|
265
|
+
return this.client.queryBuilder().collection(this.collection);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async findMany(args: FindManyArgs<T> = {}): Promise<T[]> {
|
|
269
|
+
const deletedKey = args.excludeDeleted === true ? 'deletedAt'
|
|
270
|
+
: typeof args.excludeDeleted === 'string' ? args.excludeDeleted
|
|
271
|
+
: undefined;
|
|
272
|
+
let qb = applyWhere(this.qb(), args.where, deletedKey);
|
|
273
|
+
qb = applySelect(qb, args.select);
|
|
274
|
+
if (args.orderBy) {
|
|
275
|
+
const [field, dir] = Object.entries(args.orderBy)[0];
|
|
276
|
+
qb = qb.sortBy(field, dir as 'asc' | 'desc');
|
|
277
|
+
}
|
|
278
|
+
if (args.take !== undefined) qb = qb.limit(args.take);
|
|
279
|
+
if (args.skip !== undefined) qb = qb.offset(args.skip);
|
|
280
|
+
const result = await qb.execute<T>();
|
|
281
|
+
return result.records ?? [];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async findFirst(args: FindFirstArgs<T> = {}): Promise<T | null> {
|
|
285
|
+
const results = await this.findMany({ ...args, take: 1 });
|
|
286
|
+
return results[0] ?? null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async findUnique(args: FindUniqueArgs<T>): Promise<T | null> {
|
|
290
|
+
return this.findFirst(args);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async create(args: CreateArgs<T>): Promise<StoreResponse> {
|
|
294
|
+
return this.client.store({
|
|
295
|
+
collection: this.collection,
|
|
296
|
+
data: [args.data as Record<string, any>],
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Sends all records in a single store call — not N round-trips. */
|
|
301
|
+
async createMany(args: CreateManyArgs<T>): Promise<StoreResponse> {
|
|
302
|
+
return this.client.store({
|
|
303
|
+
collection: this.collection,
|
|
304
|
+
data: args.data as Record<string, any>[],
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Update a document by finding it with `identifierFind`, merging `patch` on top,
|
|
310
|
+
* and storing the result. OnDB deduplicates on unique fields, so this effectively
|
|
311
|
+
* replaces the existing record.
|
|
312
|
+
*
|
|
313
|
+
* @throws if no document matches `identifierFind`
|
|
314
|
+
*/
|
|
315
|
+
async updateDocument(identifierFind: WhereInput<T>, patch: Partial<T>): Promise<StoreResponse> {
|
|
316
|
+
const existing = await this.findFirst({ where: identifierFind });
|
|
317
|
+
if (!existing) throw new Error(`updateDocument: no document found matching the identifier`);
|
|
318
|
+
return this.client.store({
|
|
319
|
+
collection: this.collection,
|
|
320
|
+
data: [{ ...existing, ...patch }],
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Soft-delete a document by finding it with `identifierFind` and storing it back
|
|
326
|
+
* with `deletedAt: Date.now()` (plus any optional extra `patch`).
|
|
327
|
+
* OnDB deduplicates on unique fields, so this effectively marks the record as deleted.
|
|
328
|
+
*
|
|
329
|
+
* @throws if no document matches `identifierFind`
|
|
330
|
+
*/
|
|
331
|
+
async deleteDocument(identifierFind: WhereInput<T>, patch?: Partial<T>): Promise<StoreResponse> {
|
|
332
|
+
const existing = await this.findFirst({ where: identifierFind });
|
|
333
|
+
if (!existing) throw new Error(`deleteDocument: no document found matching the identifier`);
|
|
334
|
+
return this.client.store({
|
|
335
|
+
collection: this.collection,
|
|
336
|
+
data: [{ ...existing, deletedAt: Date.now(), ...(patch ?? {}) }],
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async count(args: CountArgs<T> = {}): Promise<number> {
|
|
341
|
+
return applyWhere(this.qb(), args.where).count();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Aggregate result keys follow the pattern: `_count`, `_sum_<field>`, `_avg_<field>`, etc.
|
|
346
|
+
* @example
|
|
347
|
+
* const result = await db.model('users').aggregate({
|
|
348
|
+
* where: { active: true },
|
|
349
|
+
* _count: true,
|
|
350
|
+
* _avg: { age: true },
|
|
351
|
+
* _sum: { balance: true },
|
|
352
|
+
* });
|
|
353
|
+
* // result: { _count: 42, _avg_age: 28.5, _sum_balance: 99000 }
|
|
354
|
+
*/
|
|
355
|
+
async aggregate(args: AggregateArgs<T>): Promise<Record<string, any>> {
|
|
356
|
+
const qb = applyWhere(this.qb(), args.where);
|
|
357
|
+
return qb.runAggregate(buildAggregateSpec(args));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -80,12 +80,13 @@ export class QueryBuilder {
|
|
|
80
80
|
return new WhereClause(this, fieldName);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
// Server-side JOIN with Scepter query engine (
|
|
84
|
-
//
|
|
85
|
-
serverJoin(alias: string, model: string, resolve: { find?: any; select?: SelectionMap }): QueryBuilder {
|
|
83
|
+
// Server-side JOIN with Scepter query engine (low-level)
|
|
84
|
+
// many: true → array result (joinMany), false → single object (joinOne), undefined → Scepter default
|
|
85
|
+
serverJoin(alias: string, model: string, resolve: { find?: any; select?: SelectionMap }, many?: boolean): QueryBuilder {
|
|
86
86
|
this.serverJoinConfigs.push({
|
|
87
87
|
alias,
|
|
88
88
|
model,
|
|
89
|
+
many,
|
|
89
90
|
resolve
|
|
90
91
|
});
|
|
91
92
|
return this;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { QueryBuilder } from '../index';
|
|
2
|
+
import { MockHttpClient } from './setup';
|
|
3
|
+
|
|
4
|
+
const APP = 'test_app';
|
|
5
|
+
|
|
6
|
+
describe('Join wire format — getQueryRequest() equality', () => {
|
|
7
|
+
function builder() {
|
|
8
|
+
return new QueryBuilder(new MockHttpClient(), 'http://localhost:9092', APP);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
12
|
+
// joinWith ≡ serverJoin (no many)
|
|
13
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
test('joinWith produces identical request to serverJoin', () => {
|
|
16
|
+
const withServerJoin = builder()
|
|
17
|
+
.collection('tweets')
|
|
18
|
+
.serverJoin('author', 'users', {
|
|
19
|
+
find: { address: { is: '$data.author' } },
|
|
20
|
+
select: { display_name: true, avatar_url: true },
|
|
21
|
+
})
|
|
22
|
+
.getQueryRequest();
|
|
23
|
+
|
|
24
|
+
const withJoinWith = builder()
|
|
25
|
+
.collection('tweets')
|
|
26
|
+
.joinWith('author', 'users')
|
|
27
|
+
.onField('address').equals('$data.author')
|
|
28
|
+
.selectFields(['display_name', 'avatar_url'])
|
|
29
|
+
.build()
|
|
30
|
+
.getQueryRequest();
|
|
31
|
+
|
|
32
|
+
expect(withJoinWith).toEqual(withServerJoin);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// joinOne ≡ serverJoin(…, false)
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
test('joinOne produces identical request to serverJoin with many=false', () => {
|
|
40
|
+
const withServerJoin = builder()
|
|
41
|
+
.collection('tweets')
|
|
42
|
+
.serverJoin('author', 'users', {
|
|
43
|
+
find: { address: { is: '$data.author' } },
|
|
44
|
+
select: { display_name: true, avatar_url: true },
|
|
45
|
+
}, false)
|
|
46
|
+
.getQueryRequest();
|
|
47
|
+
|
|
48
|
+
const withJoinOne = builder()
|
|
49
|
+
.collection('tweets')
|
|
50
|
+
.joinOne('author', 'users')
|
|
51
|
+
.onField('address').equals('$data.author')
|
|
52
|
+
.selectFields(['display_name', 'avatar_url'])
|
|
53
|
+
.build()
|
|
54
|
+
.getQueryRequest();
|
|
55
|
+
|
|
56
|
+
expect(withJoinOne).toEqual(withServerJoin);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
60
|
+
// joinMany ≡ serverJoin(…, true)
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
test('joinMany produces identical request to serverJoin with many=true', () => {
|
|
64
|
+
const withServerJoin = builder()
|
|
65
|
+
.collection('users')
|
|
66
|
+
.serverJoin('tweets', 'tweets', {
|
|
67
|
+
find: { author: { is: '$data.address' } },
|
|
68
|
+
select: { id: true, content: true, created_at: true },
|
|
69
|
+
}, true)
|
|
70
|
+
.getQueryRequest();
|
|
71
|
+
|
|
72
|
+
const withJoinMany = builder()
|
|
73
|
+
.collection('users')
|
|
74
|
+
.joinMany('tweets', 'tweets')
|
|
75
|
+
.onField('author').equals('$data.address')
|
|
76
|
+
.selectFields(['id', 'content', 'created_at'])
|
|
77
|
+
.build()
|
|
78
|
+
.getQueryRequest();
|
|
79
|
+
|
|
80
|
+
expect(withJoinMany).toEqual(withServerJoin);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
84
|
+
// Multiple joins
|
|
85
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
test('multiple joins: serverJoin chain equals joinOne + joinMany chain', () => {
|
|
88
|
+
const withServerJoin = builder()
|
|
89
|
+
.collection('tweets')
|
|
90
|
+
.serverJoin('author', 'users', {
|
|
91
|
+
find: { address: { is: '$data.author' } },
|
|
92
|
+
select: { display_name: true },
|
|
93
|
+
}, false)
|
|
94
|
+
.serverJoin('likes', 'likes', {
|
|
95
|
+
find: { tweet_id: { is: '$data.id' } },
|
|
96
|
+
select: { user: true },
|
|
97
|
+
}, true)
|
|
98
|
+
.getQueryRequest();
|
|
99
|
+
|
|
100
|
+
const withFluent = builder()
|
|
101
|
+
.collection('tweets')
|
|
102
|
+
.joinOne('author', 'users')
|
|
103
|
+
.onField('address').equals('$data.author')
|
|
104
|
+
.selectFields(['display_name'])
|
|
105
|
+
.build()
|
|
106
|
+
.joinMany('likes', 'likes')
|
|
107
|
+
.onField('tweet_id').equals('$data.id')
|
|
108
|
+
.selectFields(['user'])
|
|
109
|
+
.build()
|
|
110
|
+
.getQueryRequest();
|
|
111
|
+
|
|
112
|
+
expect(withFluent).toEqual(withServerJoin);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
116
|
+
// Joins + whereField
|
|
117
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
test('joins combined with whereField: serverJoin equals joinOne', () => {
|
|
120
|
+
const withServerJoin = builder()
|
|
121
|
+
.collection('tweets')
|
|
122
|
+
.whereField('reply_to_id').isNull()
|
|
123
|
+
.serverJoin('author', 'users', {
|
|
124
|
+
find: { address: { is: '$data.author' } },
|
|
125
|
+
select: {},
|
|
126
|
+
}, false)
|
|
127
|
+
.getQueryRequest();
|
|
128
|
+
|
|
129
|
+
const withJoinOne = builder()
|
|
130
|
+
.collection('tweets')
|
|
131
|
+
.whereField('reply_to_id').isNull()
|
|
132
|
+
.joinOne('author', 'users')
|
|
133
|
+
.onField('address').equals('$data.author')
|
|
134
|
+
.build()
|
|
135
|
+
.getQueryRequest();
|
|
136
|
+
|
|
137
|
+
expect(withJoinOne).toEqual(withServerJoin);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
141
|
+
// getQueryRequest() includes root; buildRawQuery() does not
|
|
142
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
test('getQueryRequest includes root, buildRawQuery does not', () => {
|
|
145
|
+
const qb = builder()
|
|
146
|
+
.collection('tweets')
|
|
147
|
+
.joinOne('author', 'users')
|
|
148
|
+
.onField('address').equals('$data.author')
|
|
149
|
+
.build();
|
|
150
|
+
|
|
151
|
+
expect(qb.getQueryRequest().root).toBe(`${APP}::tweets`);
|
|
152
|
+
expect((qb.buildRawQuery() as any).root).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
});
|