webspresso 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Webspresso ORM - Query Builder
3
+ * Fluent query builder wrapping Knex
4
+ * @module core/orm/query-builder
5
+ */
6
+
7
+ const { applyScopes, createScopeContext } = require('./scopes');
8
+
9
+ /**
10
+ * Create a new query builder
11
+ * @param {import('./types').ModelDefinition} model - Model definition
12
+ * @param {import('knex').Knex|import('knex').Knex.Transaction} knex - Knex instance or transaction
13
+ * @param {import('./types').ScopeContext} [initialContext] - Initial scope context
14
+ * @returns {QueryBuilder}
15
+ */
16
+ function createQueryBuilder(model, knex, initialContext) {
17
+ return new QueryBuilder(model, knex, initialContext);
18
+ }
19
+
20
+ /**
21
+ * QueryBuilder class
22
+ * Provides a fluent interface for building SQL queries
23
+ */
24
+ class QueryBuilder {
25
+ /**
26
+ * @param {import('./types').ModelDefinition} model
27
+ * @param {import('knex').Knex|import('knex').Knex.Transaction} knex
28
+ * @param {import('./types').ScopeContext} [initialContext]
29
+ */
30
+ constructor(model, knex, initialContext) {
31
+ this.model = model;
32
+ this.knex = knex;
33
+ this.scopeContext = initialContext || createScopeContext();
34
+
35
+ /** @type {import('./types').QueryState} */
36
+ this.state = {
37
+ wheres: [],
38
+ orderBys: [],
39
+ selects: [],
40
+ withs: [],
41
+ limitValue: undefined,
42
+ offsetValue: undefined,
43
+ scopeContext: this.scopeContext,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Select specific columns
49
+ * @param {...string} columns - Columns to select
50
+ * @returns {this}
51
+ */
52
+ select(...columns) {
53
+ this.state.selects.push(...columns.flat());
54
+ return this;
55
+ }
56
+
57
+ /**
58
+ * Add a WHERE clause
59
+ * @param {string|Object} columnOrConditions - Column name or conditions object
60
+ * @param {string|*} [operatorOrValue] - Operator or value
61
+ * @param {*} [value] - Value (if operator provided)
62
+ * @returns {this}
63
+ */
64
+ where(columnOrConditions, operatorOrValue, value) {
65
+ // Object form: where({ column: value, ... })
66
+ if (typeof columnOrConditions === 'object' && columnOrConditions !== null) {
67
+ for (const [col, val] of Object.entries(columnOrConditions)) {
68
+ this.state.wheres.push({
69
+ column: col,
70
+ operator: '=',
71
+ value: val,
72
+ boolean: 'and',
73
+ });
74
+ }
75
+ return this;
76
+ }
77
+
78
+ // Three-argument form: where('column', '>', value)
79
+ if (value !== undefined) {
80
+ this.state.wheres.push({
81
+ column: columnOrConditions,
82
+ operator: operatorOrValue,
83
+ value,
84
+ boolean: 'and',
85
+ });
86
+ return this;
87
+ }
88
+
89
+ // Two-argument form: where('column', value)
90
+ this.state.wheres.push({
91
+ column: columnOrConditions,
92
+ operator: '=',
93
+ value: operatorOrValue,
94
+ boolean: 'and',
95
+ });
96
+ return this;
97
+ }
98
+
99
+ /**
100
+ * Add an OR WHERE clause
101
+ * @param {string|Object} columnOrConditions
102
+ * @param {string|*} [operatorOrValue]
103
+ * @param {*} [value]
104
+ * @returns {this}
105
+ */
106
+ orWhere(columnOrConditions, operatorOrValue, value) {
107
+ const startIndex = this.state.wheres.length;
108
+ this.where(columnOrConditions, operatorOrValue, value);
109
+
110
+ // Mark the new clauses as OR
111
+ for (let i = startIndex; i < this.state.wheres.length; i++) {
112
+ this.state.wheres[i].boolean = 'or';
113
+ }
114
+ return this;
115
+ }
116
+
117
+ /**
118
+ * Add a WHERE IN clause
119
+ * @param {string} column - Column name
120
+ * @param {Array} values - Values array
121
+ * @returns {this}
122
+ */
123
+ whereIn(column, values) {
124
+ this.state.wheres.push({
125
+ column,
126
+ operator: 'in',
127
+ value: values,
128
+ boolean: 'and',
129
+ });
130
+ return this;
131
+ }
132
+
133
+ /**
134
+ * Add a WHERE NOT IN clause
135
+ * @param {string} column - Column name
136
+ * @param {Array} values - Values array
137
+ * @returns {this}
138
+ */
139
+ whereNotIn(column, values) {
140
+ this.state.wheres.push({
141
+ column,
142
+ operator: 'not in',
143
+ value: values,
144
+ boolean: 'and',
145
+ });
146
+ return this;
147
+ }
148
+
149
+ /**
150
+ * Add a WHERE NULL clause
151
+ * @param {string} column - Column name
152
+ * @returns {this}
153
+ */
154
+ whereNull(column) {
155
+ this.state.wheres.push({
156
+ column,
157
+ operator: 'is null',
158
+ value: null,
159
+ boolean: 'and',
160
+ });
161
+ return this;
162
+ }
163
+
164
+ /**
165
+ * Add a WHERE NOT NULL clause
166
+ * @param {string} column - Column name
167
+ * @returns {this}
168
+ */
169
+ whereNotNull(column) {
170
+ this.state.wheres.push({
171
+ column,
172
+ operator: 'is not null',
173
+ value: null,
174
+ boolean: 'and',
175
+ });
176
+ return this;
177
+ }
178
+
179
+ /**
180
+ * Add ORDER BY clause
181
+ * @param {string} column - Column name
182
+ * @param {'asc'|'desc'} [direction='asc'] - Sort direction
183
+ * @returns {this}
184
+ */
185
+ orderBy(column, direction = 'asc') {
186
+ this.state.orderBys.push({ column, direction });
187
+ return this;
188
+ }
189
+
190
+ /**
191
+ * Set LIMIT
192
+ * @param {number} limit - Limit value
193
+ * @returns {this}
194
+ */
195
+ limit(limit) {
196
+ this.state.limitValue = limit;
197
+ return this;
198
+ }
199
+
200
+ /**
201
+ * Set OFFSET
202
+ * @param {number} offset - Offset value
203
+ * @returns {this}
204
+ */
205
+ offset(offset) {
206
+ this.state.offsetValue = offset;
207
+ return this;
208
+ }
209
+
210
+ /**
211
+ * Add relations to eager load
212
+ * @param {...string} relations - Relation names
213
+ * @returns {this}
214
+ */
215
+ with(...relations) {
216
+ this.state.withs.push(...relations.flat());
217
+ return this;
218
+ }
219
+
220
+ /**
221
+ * Include soft-deleted records
222
+ * @returns {this}
223
+ */
224
+ withTrashed() {
225
+ this.scopeContext.withTrashed = true;
226
+ return this;
227
+ }
228
+
229
+ /**
230
+ * Only include soft-deleted records
231
+ * @returns {this}
232
+ */
233
+ onlyTrashed() {
234
+ this.scopeContext.onlyTrashed = true;
235
+ return this;
236
+ }
237
+
238
+ /**
239
+ * Set tenant context
240
+ * @param {*} tenantId - Tenant ID
241
+ * @returns {this}
242
+ */
243
+ forTenant(tenantId) {
244
+ this.scopeContext.tenantId = tenantId;
245
+ return this;
246
+ }
247
+
248
+ /**
249
+ * Build the Knex query
250
+ * @returns {import('knex').Knex.QueryBuilder}
251
+ */
252
+ toKnex() {
253
+ let qb = this.knex(this.model.table);
254
+
255
+ // Apply global scopes
256
+ qb = applyScopes(qb, this.scopeContext, this.model);
257
+
258
+ // Apply selects
259
+ if (this.state.selects.length > 0) {
260
+ qb = qb.select(this.state.selects);
261
+ }
262
+
263
+ // Apply wheres
264
+ for (const where of this.state.wheres) {
265
+ const method = where.boolean === 'or' ? 'orWhere' : 'where';
266
+
267
+ switch (where.operator) {
268
+ case 'in':
269
+ qb = where.boolean === 'or'
270
+ ? qb.orWhereIn(where.column, where.value)
271
+ : qb.whereIn(where.column, where.value);
272
+ break;
273
+ case 'not in':
274
+ qb = where.boolean === 'or'
275
+ ? qb.orWhereNotIn(where.column, where.value)
276
+ : qb.whereNotIn(where.column, where.value);
277
+ break;
278
+ case 'is null':
279
+ qb = where.boolean === 'or'
280
+ ? qb.orWhereNull(where.column)
281
+ : qb.whereNull(where.column);
282
+ break;
283
+ case 'is not null':
284
+ qb = where.boolean === 'or'
285
+ ? qb.orWhereNotNull(where.column)
286
+ : qb.whereNotNull(where.column);
287
+ break;
288
+ default:
289
+ qb = qb[method](where.column, where.operator, where.value);
290
+ }
291
+ }
292
+
293
+ // Apply order by
294
+ for (const orderBy of this.state.orderBys) {
295
+ qb = qb.orderBy(orderBy.column, orderBy.direction);
296
+ }
297
+
298
+ // Apply limit
299
+ if (this.state.limitValue !== undefined) {
300
+ qb = qb.limit(this.state.limitValue);
301
+ }
302
+
303
+ // Apply offset
304
+ if (this.state.offsetValue !== undefined) {
305
+ qb = qb.offset(this.state.offsetValue);
306
+ }
307
+
308
+ return qb;
309
+ }
310
+
311
+ /**
312
+ * Execute query and return first result
313
+ * @returns {Promise<Object|null>}
314
+ */
315
+ async first() {
316
+ const qb = this.toKnex().first();
317
+ const result = await qb;
318
+ return result || null;
319
+ }
320
+
321
+ /**
322
+ * Execute query and return all results
323
+ * @returns {Promise<Object[]>}
324
+ */
325
+ async list() {
326
+ return this.toKnex();
327
+ }
328
+
329
+ /**
330
+ * Execute query and return count
331
+ * @returns {Promise<number>}
332
+ */
333
+ async count() {
334
+ const result = await this.toKnex().count('* as count').first();
335
+ return parseInt(result?.count || 0, 10);
336
+ }
337
+
338
+ /**
339
+ * Check if any records exist
340
+ * @returns {Promise<boolean>}
341
+ */
342
+ async exists() {
343
+ const count = await this.count();
344
+ return count > 0;
345
+ }
346
+
347
+ /**
348
+ * Execute query with pagination
349
+ * @param {number} [page=1] - Page number (1-indexed)
350
+ * @param {number} [perPage=15] - Items per page
351
+ * @returns {Promise<import('./types').PaginatedResult>}
352
+ */
353
+ async paginate(page = 1, perPage = 15) {
354
+ // Clone state for count query
355
+ const countQb = this.toKnex().clone();
356
+
357
+ // Get total count
358
+ const countResult = await countQb.count('* as count').first();
359
+ const total = parseInt(countResult?.count || 0, 10);
360
+
361
+ // Get paginated data
362
+ const offset = (page - 1) * perPage;
363
+ const data = await this.toKnex().limit(perPage).offset(offset);
364
+
365
+ return {
366
+ data,
367
+ total,
368
+ page,
369
+ perPage,
370
+ totalPages: Math.ceil(total / perPage),
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Delete matching records
376
+ * @returns {Promise<number>} Number of deleted records
377
+ */
378
+ async delete() {
379
+ return this.toKnex().delete();
380
+ }
381
+
382
+ /**
383
+ * Update matching records
384
+ * @param {Object} data - Data to update
385
+ * @returns {Promise<number>} Number of updated records
386
+ */
387
+ async update(data) {
388
+ return this.toKnex().update(data);
389
+ }
390
+
391
+ /**
392
+ * Get the relations to eager load
393
+ * @returns {string[]}
394
+ */
395
+ getWiths() {
396
+ return [...this.state.withs];
397
+ }
398
+
399
+ /**
400
+ * Get the current scope context
401
+ * @returns {import('./types').ScopeContext}
402
+ */
403
+ getScopeContext() {
404
+ return { ...this.scopeContext };
405
+ }
406
+
407
+ /**
408
+ * Clone the query builder
409
+ * @returns {QueryBuilder}
410
+ */
411
+ clone() {
412
+ const cloned = new QueryBuilder(this.model, this.knex, { ...this.scopeContext });
413
+ cloned.state = {
414
+ wheres: [...this.state.wheres],
415
+ orderBys: [...this.state.orderBys],
416
+ selects: [...this.state.selects],
417
+ withs: [...this.state.withs],
418
+ limitValue: this.state.limitValue,
419
+ offsetValue: this.state.offsetValue,
420
+ scopeContext: { ...this.state.scopeContext },
421
+ };
422
+ return cloned;
423
+ }
424
+ }
425
+
426
+ module.exports = {
427
+ createQueryBuilder,
428
+ QueryBuilder,
429
+ };
430
+