webspresso 0.0.5 → 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,178 @@
1
+ /**
2
+ * Webspresso ORM - Model Definition
3
+ * Define models and maintain a registry
4
+ * @module core/orm/model
5
+ */
6
+
7
+ const { extractColumnsFromSchema } = require('./schema-helpers');
8
+
9
+ /**
10
+ * Global model registry
11
+ * @type {Map<string, import('./types').ModelDefinition>}
12
+ */
13
+ const modelRegistry = new Map();
14
+
15
+ /**
16
+ * Define a new model
17
+ * @param {import('./types').ModelOptions} options - Model configuration
18
+ * @returns {import('./types').ModelDefinition}
19
+ */
20
+ function defineModel(options) {
21
+ const {
22
+ name,
23
+ table,
24
+ schema,
25
+ primaryKey = 'id',
26
+ relations = {},
27
+ scopes = {},
28
+ } = options;
29
+
30
+ // Validate required fields
31
+ if (!name || typeof name !== 'string') {
32
+ throw new Error('Model name is required and must be a string');
33
+ }
34
+ if (!table || typeof table !== 'string') {
35
+ throw new Error('Model table is required and must be a string');
36
+ }
37
+ if (!schema || typeof schema.parse !== 'function') {
38
+ throw new Error('Model schema is required and must be a Zod schema');
39
+ }
40
+
41
+ // Check for duplicate registration
42
+ if (modelRegistry.has(name)) {
43
+ throw new Error(`Model "${name}" is already defined`);
44
+ }
45
+
46
+ // Extract column metadata from schema
47
+ const columns = extractColumnsFromSchema(schema);
48
+
49
+ // Validate relations
50
+ for (const [relationName, relation] of Object.entries(relations)) {
51
+ if (!['belongsTo', 'hasMany', 'hasOne'].includes(relation.type)) {
52
+ throw new Error(
53
+ `Invalid relation type "${relation.type}" for "${relationName}" in model "${name}"`
54
+ );
55
+ }
56
+ if (typeof relation.model !== 'function') {
57
+ throw new Error(
58
+ `Relation "${relationName}" in model "${name}" must have a model function`
59
+ );
60
+ }
61
+ if (!relation.foreignKey || typeof relation.foreignKey !== 'string') {
62
+ throw new Error(
63
+ `Relation "${relationName}" in model "${name}" must have a foreignKey string`
64
+ );
65
+ }
66
+ }
67
+
68
+ // Create model definition
69
+ const model = {
70
+ name,
71
+ table,
72
+ schema,
73
+ primaryKey,
74
+ relations,
75
+ scopes: {
76
+ softDelete: scopes.softDelete || false,
77
+ timestamps: scopes.timestamps || false,
78
+ tenant: scopes.tenant || null,
79
+ },
80
+ columns,
81
+ };
82
+
83
+ // Register model
84
+ modelRegistry.set(name, model);
85
+
86
+ return model;
87
+ }
88
+
89
+ /**
90
+ * Get a model by name
91
+ * @param {string} name - Model name
92
+ * @returns {import('./types').ModelDefinition|undefined}
93
+ */
94
+ function getModel(name) {
95
+ return modelRegistry.get(name);
96
+ }
97
+
98
+ /**
99
+ * Get all registered models
100
+ * @returns {Map<string, import('./types').ModelDefinition>}
101
+ */
102
+ function getAllModels() {
103
+ return new Map(modelRegistry);
104
+ }
105
+
106
+ /**
107
+ * Check if a model exists
108
+ * @param {string} name - Model name
109
+ * @returns {boolean}
110
+ */
111
+ function hasModel(name) {
112
+ return modelRegistry.has(name);
113
+ }
114
+
115
+ /**
116
+ * Clear the model registry (useful for testing)
117
+ */
118
+ function clearRegistry() {
119
+ modelRegistry.clear();
120
+ }
121
+
122
+ /**
123
+ * Unregister a model by name
124
+ * @param {string} name - Model name
125
+ * @returns {boolean} Whether the model was removed
126
+ */
127
+ function unregisterModel(name) {
128
+ return modelRegistry.delete(name);
129
+ }
130
+
131
+ /**
132
+ * Resolve a relation's model (handles lazy loading)
133
+ * @param {import('./types').RelationDefinition} relation - Relation definition
134
+ * @returns {import('./types').ModelDefinition}
135
+ */
136
+ function resolveRelationModel(relation) {
137
+ const model = relation.model();
138
+ if (!model || !model.name) {
139
+ throw new Error('Invalid relation model reference');
140
+ }
141
+ return model;
142
+ }
143
+
144
+ /**
145
+ * Get the foreign key column info for a relation
146
+ * @param {import('./types').ModelDefinition} model - Parent model
147
+ * @param {string} relationName - Relation name
148
+ * @returns {{ localKey: string, foreignKey: string, relatedModel: import('./types').ModelDefinition }}
149
+ */
150
+ function getRelationKeys(model, relationName) {
151
+ const relation = model.relations[relationName];
152
+ if (!relation) {
153
+ throw new Error(`Relation "${relationName}" not found on model "${model.name}"`);
154
+ }
155
+
156
+ const relatedModel = resolveRelationModel(relation);
157
+ const localKey = relation.localKey || model.primaryKey;
158
+
159
+ return {
160
+ localKey,
161
+ foreignKey: relation.foreignKey,
162
+ relatedModel,
163
+ };
164
+ }
165
+
166
+ module.exports = {
167
+ defineModel,
168
+ getModel,
169
+ getAllModels,
170
+ hasModel,
171
+ clearRegistry,
172
+ unregisterModel,
173
+ resolveRelationModel,
174
+ getRelationKeys,
175
+ // Export registry for testing
176
+ _registry: modelRegistry,
177
+ };
178
+
@@ -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
+