@onchaindb/sdk 0.4.0 → 0.4.2
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/.DS_Store +0 -0
- package/.claude/settings.local.json +8 -0
- package/.gitignore +5 -0
- package/.idea/.gitignore +5 -0
- package/.idea/compiler.xml +6 -0
- package/.idea/inspectionProfiles/Project_Default.xml +6 -0
- package/.idea/jsLinters/eslint.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/prettier.xml +7 -0
- package/.idea/sdk.iml +12 -0
- package/.idea/vcs.xml +6 -0
- package/.idea/workspace.xml +257 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +11 -3
- package/dist/client.js.map +1 -1
- package/dist/database.d.ts +0 -20
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +0 -40
- package/dist/database.js.map +1 -1
- package/dist/query-sdk/tests/setup.d.ts +16 -0
- package/dist/query-sdk/tests/setup.d.ts.map +1 -0
- package/dist/query-sdk/tests/setup.js +49 -0
- package/dist/query-sdk/tests/setup.js.map +1 -0
- package/examples/basic-usage.ts +136 -0
- package/examples/blob-upload-example.ts +140 -0
- package/examples/collection-schema-example.ts +304 -0
- package/examples/server-side-joins.ts +201 -0
- package/examples/tweet-self-joins-example.ts +352 -0
- package/package-lock.json +3823 -0
- package/package.json +1 -1
- package/skills.md +1096 -0
- package/src/.env +1 -0
- package/src/batch.d.ts +121 -0
- package/src/batch.js +205 -0
- package/src/batch.ts +257 -0
- package/src/client.ts +1856 -0
- package/src/database.d.ts +268 -0
- package/src/database.js +294 -0
- package/src/database.ts +695 -0
- package/src/index.d.ts +160 -0
- package/src/index.js +186 -0
- package/src/index.ts +253 -0
- package/src/query-sdk/ConditionBuilder.ts +103 -0
- package/src/query-sdk/FieldConditionBuilder.ts +2 -0
- package/src/query-sdk/NestedBuilders.ts +186 -0
- package/src/query-sdk/OnChainDB.ts +294 -0
- package/src/query-sdk/QueryBuilder.ts +1191 -0
- package/src/query-sdk/QueryResult.ts +375 -0
- package/src/query-sdk/README.md +866 -0
- package/src/query-sdk/SelectionBuilder.ts +94 -0
- package/src/query-sdk/adapters/HttpClientAdapter.ts +249 -0
- package/src/query-sdk/dist/ConditionBuilder.d.ts +22 -0
- package/src/query-sdk/dist/ConditionBuilder.js +90 -0
- package/src/query-sdk/dist/FieldConditionBuilder.d.ts +1 -0
- package/src/query-sdk/dist/FieldConditionBuilder.js +6 -0
- package/src/query-sdk/dist/NestedBuilders.d.ts +43 -0
- package/src/query-sdk/dist/NestedBuilders.js +144 -0
- package/src/query-sdk/dist/OnChainDB.d.ts +19 -0
- package/src/query-sdk/dist/OnChainDB.js +123 -0
- package/src/query-sdk/dist/QueryBuilder.d.ts +70 -0
- package/src/query-sdk/dist/QueryBuilder.js +295 -0
- package/src/query-sdk/dist/QueryResult.d.ts +52 -0
- package/src/query-sdk/dist/QueryResult.js +293 -0
- package/src/query-sdk/dist/SelectionBuilder.d.ts +20 -0
- package/src/query-sdk/dist/SelectionBuilder.js +80 -0
- package/src/query-sdk/dist/adapters/HttpClientAdapter.d.ts +27 -0
- package/src/query-sdk/dist/adapters/HttpClientAdapter.js +170 -0
- package/src/query-sdk/dist/index.d.ts +36 -0
- package/src/query-sdk/dist/index.js +27 -0
- package/src/query-sdk/dist/operators.d.ts +56 -0
- package/src/query-sdk/dist/operators.js +289 -0
- package/src/query-sdk/dist/tests/setup.d.ts +15 -0
- package/src/query-sdk/dist/tests/setup.js +46 -0
- package/src/query-sdk/index.ts +59 -0
- package/src/query-sdk/jest.config.js +25 -0
- package/src/query-sdk/operators.ts +335 -0
- package/src/query-sdk/package.json +46 -0
- package/src/query-sdk/tests/FieldConditionBuilder.test.ts +84 -0
- package/src/query-sdk/tests/LogicalOperator.test.ts +85 -0
- package/src/query-sdk/tests/NestedBuilders.test.ts +321 -0
- package/src/query-sdk/tests/QueryBuilder.test.ts +348 -0
- package/src/query-sdk/tests/QueryResult.test.ts +464 -0
- package/src/query-sdk/tests/aggregations.test.ts +653 -0
- package/src/query-sdk/tests/comprehensive.test.ts +279 -0
- package/src/query-sdk/tests/integration.test.ts +608 -0
- package/src/query-sdk/tests/operators.test.ts +327 -0
- package/src/query-sdk/tests/setup.ts +59 -0
- package/src/query-sdk/tests/unit.test.ts +794 -0
- package/src/query-sdk/tsconfig.json +26 -0
- package/src/query-sdk/yarn.lock +3092 -0
- package/src/types.d.ts +131 -0
- package/src/types.js +46 -0
- package/src/types.ts +534 -0
- package/src/x402/index.ts +12 -0
- package/src/x402/types.ts +250 -0
- package/src/x402/utils.ts +332 -0
- package/tsconfig.json +20 -0
- package/yarn.lock +2309 -0
|
@@ -0,0 +1,1191 @@
|
|
|
1
|
+
import {QueryRequest, QueryResponse, QueryValue, SelectionMap, Val, PaymentRequiredError} from '../index';
|
|
2
|
+
import {FieldConditionBuilder, LogicalOperator} from './operators';
|
|
3
|
+
import {SelectionBuilder} from './SelectionBuilder';
|
|
4
|
+
import {ConditionBuilder} from './ConditionBuilder';
|
|
5
|
+
import {FieldMap, HttpClient} from "./index";
|
|
6
|
+
import {AxiosError} from "axios";
|
|
7
|
+
import {
|
|
8
|
+
X402PaymentRequirement,
|
|
9
|
+
X402PaymentRequiredResponse,
|
|
10
|
+
encodePaymentHeader,
|
|
11
|
+
requirementToQuote,
|
|
12
|
+
} from '../x402';
|
|
13
|
+
|
|
14
|
+
// Interface for server-side join configuration (Scepter format)
|
|
15
|
+
export interface ServerJoinConfig {
|
|
16
|
+
alias: string; // Field name for joined data
|
|
17
|
+
model: string; // Target collection to join with
|
|
18
|
+
many?: boolean; // Explicit control over result type: true=array, false=single object, undefined=default (array)
|
|
19
|
+
resolve: { // Sub-query to resolve joined data
|
|
20
|
+
find?: any; // Filter conditions for join
|
|
21
|
+
select?: SelectionMap; // Fields to select from joined collection
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// DEPRECATED: Client-side join configuration (kept for backward compatibility)
|
|
26
|
+
export interface JoinConfig {
|
|
27
|
+
parentCollection: string;
|
|
28
|
+
parentField: string;
|
|
29
|
+
childField: string;
|
|
30
|
+
alias: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Main query builder providing fluent API for building queries
|
|
34
|
+
export class QueryBuilder {
|
|
35
|
+
private findConditions?: LogicalOperator;
|
|
36
|
+
private selections?: SelectionMap;
|
|
37
|
+
private fieldMap?: FieldMap;
|
|
38
|
+
private limitValue?: number;
|
|
39
|
+
private offsetValue?: number;
|
|
40
|
+
private sortBy?: string;
|
|
41
|
+
private sortDirection?: string;
|
|
42
|
+
private includeHistoryValue?: boolean;
|
|
43
|
+
private httpClient?: HttpClient;
|
|
44
|
+
private serverUrl?: string;
|
|
45
|
+
private collectionName: string | undefined;
|
|
46
|
+
private app: string | undefined;
|
|
47
|
+
private serverJoinConfigs: ServerJoinConfig[] = []; // Server-side JOINs (Scepter)
|
|
48
|
+
private joinConfigs: JoinConfig[] = []; // DEPRECATED: Client-side JOINs
|
|
49
|
+
|
|
50
|
+
constructor(httpClient?: HttpClient, serverUrl?: string, app?: string) {
|
|
51
|
+
this.httpClient = httpClient;
|
|
52
|
+
this.serverUrl = serverUrl;
|
|
53
|
+
this.app = app;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static new(httpClient?: HttpClient, serverUrl?: string, app?: string): QueryBuilder {
|
|
57
|
+
return new QueryBuilder(httpClient, serverUrl, app);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Set HTTP client and server URL (for method chaining)
|
|
61
|
+
withHttpClient(httpClient: HttpClient): QueryBuilder {
|
|
62
|
+
this.httpClient = httpClient;
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
withServerUrl(serverUrl: string): QueryBuilder {
|
|
67
|
+
this.serverUrl = serverUrl;
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Build find conditions using a builder function
|
|
72
|
+
find(builderFn: (builder: ConditionBuilder) => LogicalOperator): QueryBuilder {
|
|
73
|
+
const conditionBuilder = new ConditionBuilder();
|
|
74
|
+
this.findConditions = builderFn(conditionBuilder);
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
collection(s: string): QueryBuilder {
|
|
79
|
+
this.collectionName = s;
|
|
80
|
+
return this;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Simple field condition (equivalent to Rust's where_field)
|
|
84
|
+
whereField(fieldName: string): WhereClause {
|
|
85
|
+
return new WhereClause(this, fieldName);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Server-side JOIN with Scepter query engine (RECOMMENDED)
|
|
89
|
+
// This generates a JOIN query that the backend executes recursively
|
|
90
|
+
serverJoin(alias: string, model: string, resolve: { find?: any; select?: SelectionMap }): QueryBuilder {
|
|
91
|
+
this.serverJoinConfigs.push({
|
|
92
|
+
alias,
|
|
93
|
+
model,
|
|
94
|
+
resolve
|
|
95
|
+
});
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Fluent JOIN builder for constructing server-side JOINs (default behavior - returns array)
|
|
100
|
+
joinWith(alias: string, model: string): JoinBuilder {
|
|
101
|
+
return new JoinBuilder(this, alias, model);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// One-to-one JOIN: Returns a single object (or null if no match)
|
|
105
|
+
// Use this when you expect at most one related record (e.g., user has one profile)
|
|
106
|
+
joinOne(alias: string, model: string): JoinBuilder {
|
|
107
|
+
return new JoinBuilder(this, alias, model, false); // many = false
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// One-to-many JOIN: Returns an array of objects (or empty array if no matches)
|
|
111
|
+
// Use this when you expect multiple related records (e.g., user has many tweets)
|
|
112
|
+
joinMany(alias: string, model: string): JoinBuilder {
|
|
113
|
+
return new JoinBuilder(this, alias, model, true); // many = true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// DEPRECATED: Client-side join (uses Promise.all to fetch related data)
|
|
117
|
+
// Use serverJoin(), joinWith(), joinOne(), or joinMany() instead for better performance
|
|
118
|
+
join(parentCollection: string, parentField: string, childField: string, alias: string): QueryBuilder {
|
|
119
|
+
console.warn('QueryBuilder.join() is deprecated. Use serverJoin(), joinOne(), or joinMany() for server-side JOINs.');
|
|
120
|
+
this.joinConfigs.push({
|
|
121
|
+
parentCollection,
|
|
122
|
+
parentField,
|
|
123
|
+
childField,
|
|
124
|
+
alias
|
|
125
|
+
});
|
|
126
|
+
return this;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Build selections using a builder function
|
|
130
|
+
select(builderFn: (builder: SelectionBuilder) => SelectionBuilder): QueryBuilder {
|
|
131
|
+
const selectionBuilder = new SelectionBuilder();
|
|
132
|
+
builderFn(selectionBuilder);
|
|
133
|
+
this.selections = selectionBuilder.build();
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Select specific fields by name
|
|
138
|
+
selectFields(fields: string[]): QueryBuilder {
|
|
139
|
+
const selectionBuilder = new SelectionBuilder();
|
|
140
|
+
selectionBuilder.fields(fields);
|
|
141
|
+
this.selections = selectionBuilder.build();
|
|
142
|
+
return this;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Select all fields (no field filtering)
|
|
146
|
+
selectAll(): QueryBuilder {
|
|
147
|
+
this.selections = SelectionBuilder.all();
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Set field mapping for the query
|
|
152
|
+
withFieldMap(fieldMap: FieldMap): QueryBuilder {
|
|
153
|
+
this.fieldMap = fieldMap;
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Set limit for results
|
|
158
|
+
limit(limit: number): QueryBuilder {
|
|
159
|
+
this.limitValue = limit;
|
|
160
|
+
return this;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Set offset for results
|
|
164
|
+
offset(offset: number): QueryBuilder {
|
|
165
|
+
this.offsetValue = offset;
|
|
166
|
+
return this;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Set sorting fields
|
|
170
|
+
orderBy(fields: string, direction?: "ASC" | "DESC"): QueryBuilder {
|
|
171
|
+
this.sortBy = fields;
|
|
172
|
+
switch (direction) {
|
|
173
|
+
case "ASC":
|
|
174
|
+
this.sortDirection = "ascending";
|
|
175
|
+
break;
|
|
176
|
+
case "DESC":
|
|
177
|
+
this.sortDirection = "descending";
|
|
178
|
+
break;
|
|
179
|
+
default:
|
|
180
|
+
this.sortDirection = "ascending";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return this;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Include all versions of records in query results (default: false, returns only latest versions)
|
|
187
|
+
// When true, returns all historical versions of each record
|
|
188
|
+
// When false or not called (default), returns only the most recent version of each record
|
|
189
|
+
includeHistory(include: boolean = true): QueryBuilder {
|
|
190
|
+
this.includeHistoryValue = include;
|
|
191
|
+
return this;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Execute the query via HTTP with join support
|
|
195
|
+
async execute<T extends Record<string, any>>(): Promise<QueryResponse<T>> {
|
|
196
|
+
if (!this.httpClient) {
|
|
197
|
+
throw new Error('HTTP client is required for query execution');
|
|
198
|
+
}
|
|
199
|
+
if (!this.serverUrl) {
|
|
200
|
+
throw new Error('Server URL is required for query execution');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Server-side JOINs are embedded in the query structure and handled by the backend
|
|
204
|
+
if (this.serverJoinConfigs.length > 0) {
|
|
205
|
+
return this.executeSimpleQuery<T>();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// DEPRECATED: Client-side joins using Promise.all
|
|
209
|
+
if (this.joinConfigs.length > 0) {
|
|
210
|
+
console.warn('Using deprecated client-side JOINs. Consider migrating to serverJoin() for better performance.');
|
|
211
|
+
return this.executeWithJoins<T>();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// No joins, execute as a normal query
|
|
215
|
+
return this.executeSimpleQuery<T>();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Execute the query and return only the latest record by metadata (updatedAt or createdAt).
|
|
220
|
+
* This is the server-side implementation - it runs the find query, sorts by metadata
|
|
221
|
+
* timestamp descending, and returns only the first (latest) record.
|
|
222
|
+
*
|
|
223
|
+
* @returns Promise resolving to the latest matching record or null if none found
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* ```typescript
|
|
227
|
+
* const latestUser = await db.queryBuilder()
|
|
228
|
+
* .collection('users')
|
|
229
|
+
* .whereField('email').equals('alice@example.com')
|
|
230
|
+
* .executeUnique<User>();
|
|
231
|
+
* ```
|
|
232
|
+
*/
|
|
233
|
+
async executeUnique<T extends Record<string, any>>(): Promise<T | null> {
|
|
234
|
+
const response = await this.execute<T>();
|
|
235
|
+
|
|
236
|
+
if (!response.records || response.records.length === 0) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Sort by metadata timestamp (updatedAt first, then createdAt) descending
|
|
241
|
+
const sortedRecords = [...response.records].sort((a: any, b: any) => {
|
|
242
|
+
const getTimestamp = (record: any): string | null => {
|
|
243
|
+
return record.updatedAt || record.updated_at ||
|
|
244
|
+
record.createdAt || record.created_at || null;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const tsA = getTimestamp(a);
|
|
248
|
+
const tsB = getTimestamp(b);
|
|
249
|
+
|
|
250
|
+
// Sort descending (latest first)
|
|
251
|
+
if (tsB && tsA) return tsB.localeCompare(tsA);
|
|
252
|
+
if (tsB && !tsA) return -1;
|
|
253
|
+
if (!tsB && tsA) return 1;
|
|
254
|
+
return 0;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
return sortedRecords[0] as T;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ===== AGGREGATION METHODS =====
|
|
261
|
+
// These methods execute server-side aggregations via the query builder
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Count the number of records matching the query conditions.
|
|
265
|
+
* Executes server-side for efficiency.
|
|
266
|
+
*
|
|
267
|
+
* @returns Promise resolving to the count of matching records
|
|
268
|
+
*
|
|
269
|
+
* @example
|
|
270
|
+
* ```typescript
|
|
271
|
+
* const activeUsers = await db.queryBuilder()
|
|
272
|
+
* .collection('users')
|
|
273
|
+
* .whereField('active').equals(true)
|
|
274
|
+
* .count();
|
|
275
|
+
* ```
|
|
276
|
+
*/
|
|
277
|
+
async count(): Promise<number> {
|
|
278
|
+
const response = await this.execute();
|
|
279
|
+
return response.records?.length || 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Sum values of a numeric field for records matching the query.
|
|
284
|
+
*
|
|
285
|
+
* @param field - The field name to sum
|
|
286
|
+
* @returns Promise resolving to the sum
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* ```typescript
|
|
290
|
+
* const totalRevenue = await db.queryBuilder()
|
|
291
|
+
* .collection('orders')
|
|
292
|
+
* .whereField('status').equals('completed')
|
|
293
|
+
* .sumBy('amount');
|
|
294
|
+
* ```
|
|
295
|
+
*/
|
|
296
|
+
async sumBy(field: string): Promise<number> {
|
|
297
|
+
const response = await this.execute();
|
|
298
|
+
if (!response.records) return 0;
|
|
299
|
+
|
|
300
|
+
return response.records.reduce((sum: number, record: any) => {
|
|
301
|
+
const value = record[field];
|
|
302
|
+
return sum + (typeof value === 'number' ? value : 0);
|
|
303
|
+
}, 0);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Calculate average of a numeric field for records matching the query.
|
|
308
|
+
*
|
|
309
|
+
* @param field - The field name to average
|
|
310
|
+
* @returns Promise resolving to the average (or 0 if no records)
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* ```typescript
|
|
314
|
+
* const avgPrice = await db.queryBuilder()
|
|
315
|
+
* .collection('products')
|
|
316
|
+
* .whereField('category').equals('electronics')
|
|
317
|
+
* .avgBy('price');
|
|
318
|
+
* ```
|
|
319
|
+
*/
|
|
320
|
+
async avgBy(field: string): Promise<number> {
|
|
321
|
+
const response = await this.execute();
|
|
322
|
+
if (!response.records || response.records.length === 0) return 0;
|
|
323
|
+
|
|
324
|
+
const sum = response.records.reduce((total: number, record: any) => {
|
|
325
|
+
const value = record[field];
|
|
326
|
+
return total + (typeof value === 'number' ? value : 0);
|
|
327
|
+
}, 0);
|
|
328
|
+
|
|
329
|
+
return sum / response.records.length;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Find maximum value of a field for records matching the query.
|
|
334
|
+
*
|
|
335
|
+
* @param field - The field name to find maximum
|
|
336
|
+
* @returns Promise resolving to the maximum value or null if no records
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```typescript
|
|
340
|
+
* const highestPrice = await db.queryBuilder()
|
|
341
|
+
* .collection('products')
|
|
342
|
+
* .maxBy('price');
|
|
343
|
+
* ```
|
|
344
|
+
*/
|
|
345
|
+
async maxBy<T = any>(field: string): Promise<T | null> {
|
|
346
|
+
const response = await this.execute();
|
|
347
|
+
if (!response.records || response.records.length === 0) return null;
|
|
348
|
+
|
|
349
|
+
return response.records.reduce((max: any, record: any) => {
|
|
350
|
+
const value = record[field];
|
|
351
|
+
if (max === null) return value;
|
|
352
|
+
return value > max ? value : max;
|
|
353
|
+
}, null);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Find minimum value of a field for records matching the query.
|
|
358
|
+
*
|
|
359
|
+
* @param field - The field name to find minimum
|
|
360
|
+
* @returns Promise resolving to the minimum value or null if no records
|
|
361
|
+
*
|
|
362
|
+
* @example
|
|
363
|
+
* ```typescript
|
|
364
|
+
* const lowestPrice = await db.queryBuilder()
|
|
365
|
+
* .collection('products')
|
|
366
|
+
* .minBy('price');
|
|
367
|
+
* ```
|
|
368
|
+
*/
|
|
369
|
+
async minBy<T = any>(field: string): Promise<T | null> {
|
|
370
|
+
const response = await this.execute();
|
|
371
|
+
if (!response.records || response.records.length === 0) return null;
|
|
372
|
+
|
|
373
|
+
return response.records.reduce((min: any, record: any) => {
|
|
374
|
+
const value = record[field];
|
|
375
|
+
if (min === null) return value;
|
|
376
|
+
return value < min ? value : min;
|
|
377
|
+
}, null);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Get distinct values of a field for records matching the query.
|
|
382
|
+
*
|
|
383
|
+
* @param field - The field name to get distinct values
|
|
384
|
+
* @returns Promise resolving to array of distinct values
|
|
385
|
+
*
|
|
386
|
+
* @example
|
|
387
|
+
* ```typescript
|
|
388
|
+
* const categories = await db.queryBuilder()
|
|
389
|
+
* .collection('products')
|
|
390
|
+
* .distinctBy('category');
|
|
391
|
+
* ```
|
|
392
|
+
*/
|
|
393
|
+
async distinctBy<T = any>(field: string): Promise<T[]> {
|
|
394
|
+
const response = await this.execute();
|
|
395
|
+
if (!response.records) return [];
|
|
396
|
+
|
|
397
|
+
const uniqueValues = new Set<T>();
|
|
398
|
+
for (const record of response.records) {
|
|
399
|
+
const value = (record as any)[field];
|
|
400
|
+
if (value !== undefined && value !== null) {
|
|
401
|
+
uniqueValues.add(value);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return Array.from(uniqueValues);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Count distinct values of a field for records matching the query.
|
|
410
|
+
*
|
|
411
|
+
* @param field - The field name to count distinct values
|
|
412
|
+
* @returns Promise resolving to the count of distinct values
|
|
413
|
+
*
|
|
414
|
+
* @example
|
|
415
|
+
* ```typescript
|
|
416
|
+
* const uniqueCategories = await db.queryBuilder()
|
|
417
|
+
* .collection('products')
|
|
418
|
+
* .countDistinct('category');
|
|
419
|
+
* ```
|
|
420
|
+
*/
|
|
421
|
+
async countDistinct(field: string): Promise<number> {
|
|
422
|
+
const distinctValues = await this.distinctBy(field);
|
|
423
|
+
return distinctValues.length;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Start a grouped aggregation query.
|
|
428
|
+
* Returns a GroupByBuilder for specifying the aggregation operation.
|
|
429
|
+
*
|
|
430
|
+
* @param field - The field name to group by
|
|
431
|
+
* @returns GroupByQueryBuilder for chaining aggregation methods
|
|
432
|
+
*
|
|
433
|
+
* @example
|
|
434
|
+
* ```typescript
|
|
435
|
+
* const salesByCategory = await db.queryBuilder()
|
|
436
|
+
* .collection('orders')
|
|
437
|
+
* .groupBy('category')
|
|
438
|
+
* .sumBy('amount');
|
|
439
|
+
*
|
|
440
|
+
* // Returns: { "electronics": 5000, "clothing": 3000, ... }
|
|
441
|
+
* ```
|
|
442
|
+
*/
|
|
443
|
+
groupBy(field: string): GroupByQueryBuilder {
|
|
444
|
+
return new GroupByQueryBuilder(this, field);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Execute query with payment proof (for paid queries)
|
|
448
|
+
async executeWithPayment<T extends Record<string, any>>(
|
|
449
|
+
quoteId: string,
|
|
450
|
+
paymentProof: string,
|
|
451
|
+
network?: string // Optional network selection
|
|
452
|
+
): Promise<QueryResponse<T>> {
|
|
453
|
+
if (!this.httpClient) {
|
|
454
|
+
throw new Error('HTTP client is required for query execution');
|
|
455
|
+
}
|
|
456
|
+
if (!this.serverUrl) {
|
|
457
|
+
throw new Error('Server URL is required for query execution');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Create x402 payment payload using proper types
|
|
461
|
+
const x402Payload = {
|
|
462
|
+
x402Version: 1 as const,
|
|
463
|
+
scheme: 'exact' as const,
|
|
464
|
+
network: network || 'mocha-4',
|
|
465
|
+
quoteId: quoteId,
|
|
466
|
+
payload: {
|
|
467
|
+
txHash: paymentProof.toLowerCase(),
|
|
468
|
+
sender: 'unknown', // Will be extracted from tx by backend
|
|
469
|
+
timestamp: Math.floor(Date.now() / 1000)
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// Encode using x402 utility
|
|
474
|
+
const encodedPayment = encodePaymentHeader(x402Payload);
|
|
475
|
+
|
|
476
|
+
const request = this.getQueryRequest();
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
const response = await this.httpClient.post(
|
|
480
|
+
`${this.serverUrl}/list`,
|
|
481
|
+
request,
|
|
482
|
+
{
|
|
483
|
+
'Content-Type': 'application/json',
|
|
484
|
+
'X-PAYMENT': encodedPayment // NEW: x402 header
|
|
485
|
+
}
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
// Log X-PAYMENT-RESPONSE header if present
|
|
489
|
+
if (response?.headers && response.headers['x-payment-response']) {
|
|
490
|
+
console.log('✅ Payment confirmed:', JSON.parse(response.headers['x-payment-response']));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return response?.data as QueryResponse<T> || {
|
|
494
|
+
records: [],
|
|
495
|
+
total: 0,
|
|
496
|
+
page: 0,
|
|
497
|
+
limit: 0
|
|
498
|
+
} as QueryResponse<T>;
|
|
499
|
+
} catch (error) {
|
|
500
|
+
throw new Error(`Paid query execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
getQueryRequest(): QueryRequest {
|
|
505
|
+
const queryValue = this.buildQueryValue();
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
...queryValue,
|
|
509
|
+
limit: this.limitValue,
|
|
510
|
+
offset: this.offsetValue,
|
|
511
|
+
sortBy: this.sortBy,
|
|
512
|
+
root: `${this.app}::${this.collectionName}`
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Check if the query is valid (has required components)
|
|
517
|
+
isValid(): boolean {
|
|
518
|
+
// A query is valid if it has either find conditions or selections
|
|
519
|
+
return this.findConditions !== undefined || this.selections !== undefined;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Build raw query JSON (useful for debugging)
|
|
523
|
+
buildRawQuery(): QueryRequest {
|
|
524
|
+
return {
|
|
525
|
+
...this.buildQueryValue(),
|
|
526
|
+
limit: this.limitValue,
|
|
527
|
+
offset: this.offsetValue,
|
|
528
|
+
sortBy: this.sortBy
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Clone the query builder
|
|
533
|
+
clone(): QueryBuilder {
|
|
534
|
+
const cloned = new QueryBuilder(this.httpClient, this.serverUrl, this.app);
|
|
535
|
+
cloned.findConditions = this.findConditions;
|
|
536
|
+
cloned.selections = this.selections ? {...this.selections} : undefined;
|
|
537
|
+
cloned.fieldMap = this.fieldMap ? {...this.fieldMap} : undefined;
|
|
538
|
+
cloned.limitValue = this.limitValue;
|
|
539
|
+
cloned.offsetValue = this.offsetValue;
|
|
540
|
+
cloned.sortBy = this.sortBy;
|
|
541
|
+
cloned.includeHistoryValue = this.includeHistoryValue;
|
|
542
|
+
cloned.collectionName = this.collectionName;
|
|
543
|
+
cloned.serverJoinConfigs = [...this.serverJoinConfigs]; // Deep copy server join configs
|
|
544
|
+
cloned.joinConfigs = [...this.joinConfigs]; // Deep copy client join configs (deprecated)
|
|
545
|
+
return cloned;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Internal method to add a server join config (used by JoinBuilder)
|
|
549
|
+
_addServerJoin(config: ServerJoinConfig): void {
|
|
550
|
+
this.serverJoinConfigs.push(config);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Execute a simple query without joins
|
|
554
|
+
private async executeSimpleQuery<T extends Record<string, any>>(): Promise<QueryResponse<T>> {
|
|
555
|
+
const request = this.getQueryRequest();
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
const response = await this.httpClient!.post(
|
|
559
|
+
`${this.serverUrl}/list`,
|
|
560
|
+
request,
|
|
561
|
+
{'Content-Type': 'application/json'}
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
// x402: Check for 402 Payment Required
|
|
565
|
+
if (response?.status === 402 && response?.data) {
|
|
566
|
+
const x402Response = response.data as X402PaymentRequiredResponse;
|
|
567
|
+
const requirement = x402Response.accepts?.[0];
|
|
568
|
+
if (requirement) {
|
|
569
|
+
const quote = requirementToQuote(requirement, x402Response.accepts);
|
|
570
|
+
throw new PaymentRequiredError('Payment required for this query', quote);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Normal response with records
|
|
575
|
+
return response?.data as QueryResponse<T> || {
|
|
576
|
+
records: [],
|
|
577
|
+
limit: 0,
|
|
578
|
+
page: 0,
|
|
579
|
+
total: 0,
|
|
580
|
+
} as QueryResponse<T>;
|
|
581
|
+
} catch (error: any) {
|
|
582
|
+
// Re-throw PaymentRequiredError as-is
|
|
583
|
+
if (error instanceof PaymentRequiredError) {
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
const response = (error as AxiosError)?.response;
|
|
587
|
+
if (response?.status === 402 && response?.data) {
|
|
588
|
+
const x402Response = response.data as X402PaymentRequiredResponse;
|
|
589
|
+
const requirement = x402Response.accepts?.[0];
|
|
590
|
+
if (requirement) {
|
|
591
|
+
const quote = requirementToQuote(requirement, x402Response.accepts);
|
|
592
|
+
throw new PaymentRequiredError('Payment required for this query', quote);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
throw new Error(`Query execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Execute query with joins using parallel queries
|
|
600
|
+
private async executeWithJoins<T extends Record<string, any>>(): Promise<QueryResponse<T>> {
|
|
601
|
+
// First, get the main collection records
|
|
602
|
+
const mainQuery = this.clone();
|
|
603
|
+
mainQuery.joinConfigs = []; // Remove joins for main query
|
|
604
|
+
const mainResults = await mainQuery.executeSimpleQuery();
|
|
605
|
+
|
|
606
|
+
if (mainResults.records.length === 0) {
|
|
607
|
+
return mainResults as QueryResponse<T>;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// For each join, execute parallel queries and merge results
|
|
611
|
+
const joinResults: Record<string, any[]> = {};
|
|
612
|
+
|
|
613
|
+
for (const joinConfig of this.joinConfigs) {
|
|
614
|
+
// Get all unique values from the main results for the join key
|
|
615
|
+
const joinKeys = [...new Set(
|
|
616
|
+
mainResults.records
|
|
617
|
+
.map(record => record[joinConfig.childField])
|
|
618
|
+
.filter(key => key !== null && key !== undefined)
|
|
619
|
+
)];
|
|
620
|
+
|
|
621
|
+
if (joinKeys.length > 0) {
|
|
622
|
+
// Query the joined collection
|
|
623
|
+
const joinQuery = new QueryBuilder(this.httpClient, this.serverUrl, this.app);
|
|
624
|
+
const joinResponse = await joinQuery
|
|
625
|
+
.collection(joinConfig.parentCollection)
|
|
626
|
+
.whereField(joinConfig.parentField).in(joinKeys)
|
|
627
|
+
.selectAll()
|
|
628
|
+
.executeSimpleQuery();
|
|
629
|
+
|
|
630
|
+
joinResults[joinConfig.alias] = joinResponse.records;
|
|
631
|
+
} else {
|
|
632
|
+
joinResults[joinConfig.alias] = [];
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Merge join results with main results
|
|
637
|
+
const mergedRecords = mainResults.records.map(mainRecord => {
|
|
638
|
+
const merged: any = {...mainRecord};
|
|
639
|
+
|
|
640
|
+
for (const joinConfig of this.joinConfigs) {
|
|
641
|
+
const joinKey = mainRecord[joinConfig.childField];
|
|
642
|
+
const relatedRecords = joinResults[joinConfig.alias].filter(
|
|
643
|
+
joinRecord => joinRecord[joinConfig.parentField] === joinKey
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
// For one-to-many relationships, return array; for many-to-one, return single object
|
|
647
|
+
if (joinConfig.parentCollection === this.collectionName) {
|
|
648
|
+
// Self-referential join (like replies to tweets)
|
|
649
|
+
merged[joinConfig.alias] = relatedRecords;
|
|
650
|
+
} else {
|
|
651
|
+
// Cross-collection join
|
|
652
|
+
merged[joinConfig.alias] = relatedRecords.length > 0 ? relatedRecords[0] : null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return merged;
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
records: mergedRecords,
|
|
661
|
+
limit: mainResults.limit,
|
|
662
|
+
page: mainResults.page,
|
|
663
|
+
total: mainResults.total
|
|
664
|
+
} as QueryResponse<T>;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Build the query value object for HTTP requests
|
|
668
|
+
private buildQueryValue(): QueryValue {
|
|
669
|
+
let find = this.findConditions ? this.findConditions.toComposable() : {};
|
|
670
|
+
const select = this.selections || SelectionBuilder.all();
|
|
671
|
+
|
|
672
|
+
// Add server-side JOINs to the find conditions
|
|
673
|
+
// JOINs are just additional fields in the find map, they should NOT be wrapped in "and"
|
|
674
|
+
if (this.serverJoinConfigs.length > 0) {
|
|
675
|
+
for (const join of this.serverJoinConfigs) {
|
|
676
|
+
// Scepter JOIN format (untagged enum): { "alias": { "resolve": {...}, "model": "collection", "many": true/false } }
|
|
677
|
+
// The "join" key is NOT needed because QueryInner::Join uses #[serde(untagged)]
|
|
678
|
+
const joinConfig: any = {
|
|
679
|
+
resolve: join.resolve,
|
|
680
|
+
model: join.model
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
// Only include 'many' field if explicitly set
|
|
684
|
+
if (join.many !== undefined) {
|
|
685
|
+
joinConfig.many = join.many;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
find[join.alias] = joinConfig;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const queryValue: QueryValue = {
|
|
693
|
+
find,
|
|
694
|
+
select
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
// Add include_history if explicitly set
|
|
698
|
+
if (this.includeHistoryValue !== undefined) {
|
|
699
|
+
queryValue.include_history = this.includeHistoryValue;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return queryValue;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Helper class for building where clauses with method chaining
|
|
707
|
+
export class WhereClause {
|
|
708
|
+
constructor(
|
|
709
|
+
private queryBuilder: QueryBuilder,
|
|
710
|
+
private fieldName: string
|
|
711
|
+
) {
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
equals(value: Val): QueryBuilder {
|
|
715
|
+
const condition = new FieldConditionBuilder(this.fieldName).equals(value);
|
|
716
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
717
|
+
return this.queryBuilder;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
notEquals(value: Val): QueryBuilder {
|
|
721
|
+
const condition = new FieldConditionBuilder(this.fieldName).notEquals(value);
|
|
722
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
723
|
+
return this.queryBuilder;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
greaterThan(value: Val): QueryBuilder {
|
|
727
|
+
const condition = new FieldConditionBuilder(this.fieldName).greaterThan(value);
|
|
728
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
729
|
+
return this.queryBuilder;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
greaterThanOrEqual(value: Val): QueryBuilder {
|
|
733
|
+
const condition = new FieldConditionBuilder(this.fieldName).greaterThanOrEqual(value);
|
|
734
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
735
|
+
return this.queryBuilder;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
lessThan(value: Val): QueryBuilder {
|
|
739
|
+
const condition = new FieldConditionBuilder(this.fieldName).lessThan(value);
|
|
740
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
741
|
+
return this.queryBuilder;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
lessThanOrEqual(value: Val): QueryBuilder {
|
|
745
|
+
const condition = new FieldConditionBuilder(this.fieldName).lessThanOrEqual(value);
|
|
746
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
747
|
+
return this.queryBuilder;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
contains(value: string): QueryBuilder {
|
|
751
|
+
const condition = new FieldConditionBuilder(this.fieldName).contains(value);
|
|
752
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
753
|
+
return this.queryBuilder;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
startsWith(value: string): QueryBuilder {
|
|
757
|
+
const condition = new FieldConditionBuilder(this.fieldName).startsWith(value);
|
|
758
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
759
|
+
return this.queryBuilder;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
endsWith(value: string): QueryBuilder {
|
|
763
|
+
const condition = new FieldConditionBuilder(this.fieldName).endsWith(value);
|
|
764
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
765
|
+
return this.queryBuilder;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
in(values: Val[]): QueryBuilder {
|
|
769
|
+
const condition = new FieldConditionBuilder(this.fieldName).in(values);
|
|
770
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
771
|
+
return this.queryBuilder;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
notIn(values: Val[]): QueryBuilder {
|
|
775
|
+
const condition = new FieldConditionBuilder(this.fieldName).notIn(values);
|
|
776
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
777
|
+
return this.queryBuilder;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
exists(): QueryBuilder {
|
|
781
|
+
const condition = new FieldConditionBuilder(this.fieldName).exists();
|
|
782
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
783
|
+
return this.queryBuilder;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
notExists(): QueryBuilder {
|
|
787
|
+
const condition = new FieldConditionBuilder(this.fieldName).notExists();
|
|
788
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
789
|
+
return this.queryBuilder;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
isNull(): QueryBuilder {
|
|
793
|
+
const condition = new FieldConditionBuilder(this.fieldName).isNull();
|
|
794
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
795
|
+
return this.queryBuilder;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
isNotNull(): QueryBuilder {
|
|
799
|
+
const condition = new FieldConditionBuilder(this.fieldName).isNotNull();
|
|
800
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
801
|
+
return this.queryBuilder;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
regExpMatches(pattern: string): QueryBuilder {
|
|
805
|
+
const condition = new FieldConditionBuilder(this.fieldName).regExpMatches(pattern);
|
|
806
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
807
|
+
return this.queryBuilder;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
includesCaseInsensitive(value: string): QueryBuilder {
|
|
811
|
+
const condition = new FieldConditionBuilder(this.fieldName).includesCaseInsensitive(value);
|
|
812
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
813
|
+
return this.queryBuilder;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
startsWithCaseInsensitive(value: string): QueryBuilder {
|
|
817
|
+
const condition = new FieldConditionBuilder(this.fieldName).startsWithCaseInsensitive(value);
|
|
818
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
819
|
+
return this.queryBuilder;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
endsWithCaseInsensitive(value: string): QueryBuilder {
|
|
823
|
+
const condition = new FieldConditionBuilder(this.fieldName).endsWithCaseInsensitive(value);
|
|
824
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
825
|
+
return this.queryBuilder;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ===== IP OPERATORS =====
|
|
829
|
+
|
|
830
|
+
isLocalIp(): QueryBuilder {
|
|
831
|
+
const condition = new FieldConditionBuilder(this.fieldName).isLocalIp();
|
|
832
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
833
|
+
return this.queryBuilder;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
isExternalIp(): QueryBuilder {
|
|
837
|
+
const condition = new FieldConditionBuilder(this.fieldName).isExternalIp();
|
|
838
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
839
|
+
return this.queryBuilder;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ===== MISC OPERATORS =====
|
|
843
|
+
|
|
844
|
+
b64(value: string): QueryBuilder {
|
|
845
|
+
const condition = new FieldConditionBuilder(this.fieldName).b64(value);
|
|
846
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
847
|
+
return this.queryBuilder;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
inDataset(dataset: string): QueryBuilder {
|
|
851
|
+
const condition = new FieldConditionBuilder(this.fieldName).inDataset(dataset);
|
|
852
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
853
|
+
return this.queryBuilder;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
inCountry(countryCode: string): QueryBuilder {
|
|
857
|
+
const condition = new FieldConditionBuilder(this.fieldName).inCountry(countryCode);
|
|
858
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
859
|
+
return this.queryBuilder;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
cidr(cidr: string): QueryBuilder {
|
|
863
|
+
const condition = new FieldConditionBuilder(this.fieldName).cidr(cidr);
|
|
864
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
865
|
+
return this.queryBuilder;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ===== BETWEEN OPERATOR =====
|
|
869
|
+
|
|
870
|
+
between(min: any, max: any): QueryBuilder {
|
|
871
|
+
const condition = new FieldConditionBuilder(this.fieldName).between(min, max);
|
|
872
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
873
|
+
return this.queryBuilder;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ===== CONVENIENCE METHODS =====
|
|
877
|
+
|
|
878
|
+
isTrue(): QueryBuilder {
|
|
879
|
+
const condition = new FieldConditionBuilder(this.fieldName).isTrue();
|
|
880
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
881
|
+
return this.queryBuilder;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
isFalse(): QueryBuilder {
|
|
885
|
+
const condition = new FieldConditionBuilder(this.fieldName).isFalse();
|
|
886
|
+
(this.queryBuilder as any).findConditions = LogicalOperator.Condition(condition);
|
|
887
|
+
return this.queryBuilder;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Fluent builder for constructing server-side JOINs
|
|
892
|
+
export class JoinBuilder<TParent extends QueryBuilder | JoinBuilder<any> = QueryBuilder> {
|
|
893
|
+
private alias: string;
|
|
894
|
+
private model: string;
|
|
895
|
+
private findConditions: any = {};
|
|
896
|
+
private selections?: SelectionMap;
|
|
897
|
+
private parentBuilder: TParent;
|
|
898
|
+
private many?: boolean; // Explicit control over result type
|
|
899
|
+
private nestedJoins: ServerJoinConfig[] = []; // Support nested JOINs
|
|
900
|
+
|
|
901
|
+
constructor(parentBuilder: TParent, alias: string, model: string, many?: boolean) {
|
|
902
|
+
this.parentBuilder = parentBuilder;
|
|
903
|
+
this.alias = alias;
|
|
904
|
+
this.model = model;
|
|
905
|
+
this.many = many;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Add filter conditions for the joined collection
|
|
909
|
+
on(builderFn: (builder: ConditionBuilder) => LogicalOperator): this {
|
|
910
|
+
const conditionBuilder = new ConditionBuilder();
|
|
911
|
+
this.findConditions = builderFn(conditionBuilder).toComposable();
|
|
912
|
+
return this;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Add a simple equality condition (most common case)
|
|
916
|
+
onField(fieldName: string): JoinWhereClause<TParent> {
|
|
917
|
+
return new JoinWhereClause(this, fieldName);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Select specific fields from the joined collection
|
|
921
|
+
selecting(builderFn: (builder: SelectionBuilder) => SelectionBuilder): this {
|
|
922
|
+
const selectionBuilder = new SelectionBuilder();
|
|
923
|
+
builderFn(selectionBuilder);
|
|
924
|
+
this.selections = selectionBuilder.build();
|
|
925
|
+
return this;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Select specific fields by name
|
|
929
|
+
selectFields(fields: string[]): this {
|
|
930
|
+
const selectionBuilder = new SelectionBuilder();
|
|
931
|
+
selectionBuilder.fields(fields);
|
|
932
|
+
this.selections = selectionBuilder.build();
|
|
933
|
+
return this;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Select all fields from joined collection
|
|
937
|
+
selectAll(): this {
|
|
938
|
+
this.selections = SelectionBuilder.all();
|
|
939
|
+
return this;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Nested JOIN: one-to-one relationship within this JOIN
|
|
943
|
+
joinOne(alias: string, model: string): JoinBuilder<this> {
|
|
944
|
+
return new JoinBuilder(this, alias, model, false);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Nested JOIN: one-to-many relationship within this JOIN
|
|
948
|
+
joinMany(alias: string, model: string): JoinBuilder<this> {
|
|
949
|
+
return new JoinBuilder(this, alias, model, true);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Complete the JOIN and return to the parent builder
|
|
953
|
+
// Returns the parent builder's type (JoinBuilder for nested JOINs, QueryBuilder for top-level)
|
|
954
|
+
build(): TParent {
|
|
955
|
+
const joinConfig: ServerJoinConfig = {
|
|
956
|
+
alias: this.alias,
|
|
957
|
+
model: this.model,
|
|
958
|
+
many: this.many,
|
|
959
|
+
resolve: {
|
|
960
|
+
find: this.buildFindWithNestedJoins(),
|
|
961
|
+
select: this.selections || SelectionBuilder.all()
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
this.parentBuilder._addServerJoin(joinConfig);
|
|
966
|
+
return this.parentBuilder;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Build find conditions with nested JOINs embedded
|
|
970
|
+
private buildFindWithNestedJoins(): any {
|
|
971
|
+
// Start with existing find conditions
|
|
972
|
+
const find = Object.keys(this.findConditions).length > 0 ? {...this.findConditions} : {};
|
|
973
|
+
|
|
974
|
+
// Add nested JOINs to the find map
|
|
975
|
+
if (this.nestedJoins.length > 0) {
|
|
976
|
+
for (const nestedJoin of this.nestedJoins) {
|
|
977
|
+
const joinConfig: any = {
|
|
978
|
+
resolve: nestedJoin.resolve,
|
|
979
|
+
model: nestedJoin.model
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
if (nestedJoin.many !== undefined) {
|
|
983
|
+
joinConfig.many = nestedJoin.many;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
find[nestedJoin.alias] = joinConfig;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
return Object.keys(find).length > 0 ? find : undefined;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Internal method to set find conditions (used by JoinWhereClause)
|
|
994
|
+
_setFindConditions(conditions: any): void {
|
|
995
|
+
this.findConditions = conditions;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Internal method to add nested joins (used by nested JoinBuilder)
|
|
999
|
+
_addServerJoin(config: ServerJoinConfig): void {
|
|
1000
|
+
this.nestedJoins.push(config);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Helper class for building WHERE clauses in JOINs
|
|
1005
|
+
export class JoinWhereClause<TParent extends QueryBuilder | JoinBuilder<any> = QueryBuilder> {
|
|
1006
|
+
constructor(
|
|
1007
|
+
private joinBuilder: JoinBuilder<TParent>,
|
|
1008
|
+
private fieldName: string
|
|
1009
|
+
) {
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
equals(value: Val): JoinBuilder<TParent> {
|
|
1013
|
+
const condition = new FieldConditionBuilder(this.fieldName).equals(value);
|
|
1014
|
+
this.joinBuilder._setFindConditions(LogicalOperator.Condition(condition).toComposable());
|
|
1015
|
+
return this.joinBuilder;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
in(values: Val[]): JoinBuilder<TParent> {
|
|
1019
|
+
const condition = new FieldConditionBuilder(this.fieldName).in(values);
|
|
1020
|
+
this.joinBuilder._setFindConditions(LogicalOperator.Condition(condition).toComposable());
|
|
1021
|
+
return this.joinBuilder;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
greaterThan(value: Val): JoinBuilder<TParent> {
|
|
1025
|
+
const condition = new FieldConditionBuilder(this.fieldName).greaterThan(value);
|
|
1026
|
+
this.joinBuilder._setFindConditions(LogicalOperator.Condition(condition).toComposable());
|
|
1027
|
+
return this.joinBuilder;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
lessThan(value: Val): JoinBuilder<TParent> {
|
|
1031
|
+
const condition = new FieldConditionBuilder(this.fieldName).lessThan(value);
|
|
1032
|
+
this.joinBuilder._setFindConditions(LogicalOperator.Condition(condition).toComposable());
|
|
1033
|
+
return this.joinBuilder;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
isNull(): JoinBuilder<TParent> {
|
|
1037
|
+
const condition = new FieldConditionBuilder(this.fieldName).isNull();
|
|
1038
|
+
this.joinBuilder._setFindConditions(LogicalOperator.Condition(condition).toComposable());
|
|
1039
|
+
return this.joinBuilder;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
isNotNull(): JoinBuilder<TParent> {
|
|
1043
|
+
const condition = new FieldConditionBuilder(this.fieldName).isNotNull();
|
|
1044
|
+
this.joinBuilder._setFindConditions(LogicalOperator.Condition(condition).toComposable());
|
|
1045
|
+
return this.joinBuilder;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Builder for grouped aggregation queries.
|
|
1051
|
+
* Allows aggregation operations to be performed on groups of records.
|
|
1052
|
+
*
|
|
1053
|
+
* @example
|
|
1054
|
+
* ```typescript
|
|
1055
|
+
* // Count users by country
|
|
1056
|
+
* const usersByCountry = await db.queryBuilder()
|
|
1057
|
+
* .collection('users')
|
|
1058
|
+
* .groupBy('country')
|
|
1059
|
+
* .count();
|
|
1060
|
+
*
|
|
1061
|
+
* // Sum order amounts by category
|
|
1062
|
+
* const salesByCategory = await db.queryBuilder()
|
|
1063
|
+
* .collection('orders')
|
|
1064
|
+
* .whereField('status').equals('completed')
|
|
1065
|
+
* .groupBy('category')
|
|
1066
|
+
* .sumBy('amount');
|
|
1067
|
+
* ```
|
|
1068
|
+
*/
|
|
1069
|
+
export class GroupByQueryBuilder {
|
|
1070
|
+
constructor(
|
|
1071
|
+
private queryBuilder: QueryBuilder,
|
|
1072
|
+
private groupByField: string
|
|
1073
|
+
) {}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Count records in each group
|
|
1077
|
+
* @returns Promise resolving to map of group key -> count
|
|
1078
|
+
*/
|
|
1079
|
+
async count(): Promise<Record<string, number>> {
|
|
1080
|
+
const response = await this.queryBuilder.execute();
|
|
1081
|
+
if (!response.records) return {};
|
|
1082
|
+
|
|
1083
|
+
const groups: Record<string, number> = {};
|
|
1084
|
+
for (const record of response.records) {
|
|
1085
|
+
const key = this.getGroupKey(record);
|
|
1086
|
+
groups[key] = (groups[key] || 0) + 1;
|
|
1087
|
+
}
|
|
1088
|
+
return groups;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Sum a numeric field within each group
|
|
1093
|
+
* @param field - The field name to sum
|
|
1094
|
+
* @returns Promise resolving to map of group key -> sum
|
|
1095
|
+
*/
|
|
1096
|
+
async sumBy(field: string): Promise<Record<string, number>> {
|
|
1097
|
+
const response = await this.queryBuilder.execute();
|
|
1098
|
+
if (!response.records) return {};
|
|
1099
|
+
|
|
1100
|
+
const groups: Record<string, number> = {};
|
|
1101
|
+
for (const record of response.records) {
|
|
1102
|
+
const key = this.getGroupKey(record);
|
|
1103
|
+
const value = (record as any)[field];
|
|
1104
|
+
groups[key] = (groups[key] || 0) + (typeof value === 'number' ? value : 0);
|
|
1105
|
+
}
|
|
1106
|
+
return groups;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Calculate average of a numeric field within each group
|
|
1111
|
+
* @param field - The field name to average
|
|
1112
|
+
* @returns Promise resolving to map of group key -> average
|
|
1113
|
+
*/
|
|
1114
|
+
async avgBy(field: string): Promise<Record<string, number>> {
|
|
1115
|
+
const response = await this.queryBuilder.execute();
|
|
1116
|
+
if (!response.records) return {};
|
|
1117
|
+
|
|
1118
|
+
const groups: Record<string, { sum: number; count: number }> = {};
|
|
1119
|
+
for (const record of response.records) {
|
|
1120
|
+
const key = this.getGroupKey(record);
|
|
1121
|
+
const value = (record as any)[field];
|
|
1122
|
+
if (!groups[key]) {
|
|
1123
|
+
groups[key] = { sum: 0, count: 0 };
|
|
1124
|
+
}
|
|
1125
|
+
groups[key].sum += typeof value === 'number' ? value : 0;
|
|
1126
|
+
groups[key].count += 1;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const result: Record<string, number> = {};
|
|
1130
|
+
for (const [key, { sum, count }] of Object.entries(groups)) {
|
|
1131
|
+
result[key] = count > 0 ? sum / count : 0;
|
|
1132
|
+
}
|
|
1133
|
+
return result;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Find maximum value of a field within each group
|
|
1138
|
+
* @param field - The field name to find maximum
|
|
1139
|
+
* @returns Promise resolving to map of group key -> max value
|
|
1140
|
+
*/
|
|
1141
|
+
async maxBy<T = any>(field: string): Promise<Record<string, T>> {
|
|
1142
|
+
const response = await this.queryBuilder.execute();
|
|
1143
|
+
if (!response.records) return {};
|
|
1144
|
+
|
|
1145
|
+
const groups: Record<string, T> = {};
|
|
1146
|
+
for (const record of response.records) {
|
|
1147
|
+
const key = this.getGroupKey(record);
|
|
1148
|
+
const value = (record as any)[field] as T;
|
|
1149
|
+
if (!(key in groups) || value > (groups[key] as T)) {
|
|
1150
|
+
groups[key] = value;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return groups;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Find minimum value of a field within each group
|
|
1158
|
+
* @param field - The field name to find minimum
|
|
1159
|
+
* @returns Promise resolving to map of group key -> min value
|
|
1160
|
+
*/
|
|
1161
|
+
async minBy<T = any>(field: string): Promise<Record<string, T>> {
|
|
1162
|
+
const response = await this.queryBuilder.execute();
|
|
1163
|
+
if (!response.records) return {};
|
|
1164
|
+
|
|
1165
|
+
const groups: Record<string, T> = {};
|
|
1166
|
+
for (const record of response.records) {
|
|
1167
|
+
const key = this.getGroupKey(record);
|
|
1168
|
+
const value = (record as any)[field] as T;
|
|
1169
|
+
if (!(key in groups) || value < (groups[key] as T)) {
|
|
1170
|
+
groups[key] = value;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return groups;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Get the group key from a record, supporting nested field paths
|
|
1178
|
+
*/
|
|
1179
|
+
private getGroupKey(record: any): string {
|
|
1180
|
+
// Support nested field paths (e.g., "user.country")
|
|
1181
|
+
if (this.groupByField.includes('.')) {
|
|
1182
|
+
const parts = this.groupByField.split('.');
|
|
1183
|
+
let current = record;
|
|
1184
|
+
for (const part of parts) {
|
|
1185
|
+
current = current?.[part];
|
|
1186
|
+
}
|
|
1187
|
+
return String(current ?? 'null');
|
|
1188
|
+
}
|
|
1189
|
+
return String(record[this.groupByField] ?? 'null');
|
|
1190
|
+
}
|
|
1191
|
+
}
|