@rip-lang/schema 0.2.1
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/README.md +1042 -0
- package/SCHEMA.md +1113 -0
- package/emit-sql.js +460 -0
- package/emit-types.js +366 -0
- package/generate.js +144 -0
- package/grammar.rip +504 -0
- package/index.js +39 -0
- package/lexer.js +438 -0
- package/orm.js +916 -0
- package/package.json +62 -0
- package/parser.js +246 -0
- package/runtime.js +494 -0
package/orm.js
ADDED
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// @rip-lang/schema/orm — ActiveRecord-style ORM
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Rich domain models with inheritance, behavior, schema, and computed fields.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// import { Schema } from '@rip-lang/schema/orm'
|
|
9
|
+
//
|
|
10
|
+
// schema = Schema.load './app.schema', import.meta.url
|
|
11
|
+
// schema.connect 'http://localhost:4213'
|
|
12
|
+
//
|
|
13
|
+
// User = schema.model 'User',
|
|
14
|
+
// greet: -> "Hello, #{@name}!"
|
|
15
|
+
// computed:
|
|
16
|
+
// identifier: -> "#{@name} <#{@email}>"
|
|
17
|
+
//
|
|
18
|
+
// Query API:
|
|
19
|
+
// user = await User.find(id)
|
|
20
|
+
// users = await User.all()
|
|
21
|
+
// users = await User.where({ active: true }).all()
|
|
22
|
+
// users = await User.where('score > ?', 90).all()
|
|
23
|
+
// await user.save()
|
|
24
|
+
//
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
import { Schema } from './runtime.js';
|
|
28
|
+
import { toSnakeCase, pluralize } from './emit-sql.js';
|
|
29
|
+
import { Fake } from './faker.js';
|
|
30
|
+
export { Schema, Fake };
|
|
31
|
+
|
|
32
|
+
let _dbUrl = process.env.DB_URL || 'http://localhost:4213';
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Query helper
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
async function query(sql, params = []) {
|
|
39
|
+
const body = params.length > 0 ? { sql, params } : { sql };
|
|
40
|
+
const res = await fetch(`${_dbUrl}/sql`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify(body),
|
|
44
|
+
});
|
|
45
|
+
const data = await res.json();
|
|
46
|
+
if (data.error) throw new Error(data.error);
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// =============================================================================
|
|
51
|
+
// Temporal filter helper for links
|
|
52
|
+
// =============================================================================
|
|
53
|
+
|
|
54
|
+
function _temporalFilter(at, params) {
|
|
55
|
+
if (at === null) at = new Date().toISOString();
|
|
56
|
+
params.push(at, at);
|
|
57
|
+
return ` AND ("when_from" IS NULL OR "when_from" <= ?) AND ("when_till" IS NULL OR "when_till" >= ?)`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function _toCamelCase(s) {
|
|
61
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// =============================================================================
|
|
65
|
+
// Model — Base class for all domain models
|
|
66
|
+
// =============================================================================
|
|
67
|
+
|
|
68
|
+
export class Model {
|
|
69
|
+
|
|
70
|
+
// Class-level configuration (set by subclasses)
|
|
71
|
+
static table = null;
|
|
72
|
+
static database = null;
|
|
73
|
+
static primaryKey = 'id';
|
|
74
|
+
static _schema = {};
|
|
75
|
+
static _computed = {};
|
|
76
|
+
static _hooks = {};
|
|
77
|
+
static _columns = null;
|
|
78
|
+
static _relations = {};
|
|
79
|
+
static _schemaRef = null;
|
|
80
|
+
static _softDelete = false;
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Schema definition (called by subclass)
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
static schema(fields) {
|
|
87
|
+
this._schema = fields;
|
|
88
|
+
this._columns = {};
|
|
89
|
+
for (const name in fields) {
|
|
90
|
+
this._columns[name] = fields[name].column || name;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Computed fields definition (called by subclass)
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
static computed(fields) {
|
|
99
|
+
this._computed = fields;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Load schema from a parsed .schema AST (bridges runtime → ORM)
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
static fromSchema(schemaInstance, modelName) {
|
|
107
|
+
const model = schemaInstance.getModel(modelName);
|
|
108
|
+
if (!model) throw new Error(`Model '${modelName}' not found in schema`);
|
|
109
|
+
|
|
110
|
+
// Derive table name: UserProfile → user_profiles, Person → people
|
|
111
|
+
if (!this.table) {
|
|
112
|
+
this.table = pluralize(toSnakeCase(modelName));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Models always have an implicit id primary key (added by emit-sql.js)
|
|
116
|
+
const fields = { id: { type: 'uuid', primary: true } };
|
|
117
|
+
|
|
118
|
+
// Convert runtime field definitions → ORM schema format
|
|
119
|
+
for (const [name, field] of model.fields) {
|
|
120
|
+
const def = {};
|
|
121
|
+
const type = typeof field.type === 'object' && field.type?.array ? 'array' : field.type;
|
|
122
|
+
if (type) def.type = type;
|
|
123
|
+
if (field.required) def.required = true;
|
|
124
|
+
if (field.unique) def.unique = true;
|
|
125
|
+
if (field.constraints) {
|
|
126
|
+
if (field.constraints.min != null) def.min = field.constraints.min;
|
|
127
|
+
if (field.constraints.max != null) def.max = field.constraints.max;
|
|
128
|
+
if (field.constraints.default != null) def.default = field.constraints.default;
|
|
129
|
+
}
|
|
130
|
+
if (field.attrs?.primary) { def.primary = true; }
|
|
131
|
+
// Map camelCase field name → snake_case column name
|
|
132
|
+
const col = toSnakeCase(name);
|
|
133
|
+
if (col !== name) def.column = col;
|
|
134
|
+
fields[name] = def;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Add relationship foreign keys
|
|
138
|
+
if (model.directives?.belongsTo) {
|
|
139
|
+
for (const rel of model.directives.belongsTo) {
|
|
140
|
+
const fk = toSnakeCase(rel.model) + '_id';
|
|
141
|
+
if (!fields[fk]) fields[fk] = { type: 'uuid' };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Add timestamp fields
|
|
146
|
+
if (model.directives?.timestamps) {
|
|
147
|
+
fields.created_at = { type: 'datetime' };
|
|
148
|
+
fields.updated_at = { type: 'datetime' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Add soft delete field
|
|
152
|
+
if (model.directives?.softDelete) {
|
|
153
|
+
fields.deleted_at = { type: 'datetime' };
|
|
154
|
+
this._softDelete = true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Store relationship metadata for lazy loading
|
|
158
|
+
const relations = {};
|
|
159
|
+
if (model.directives?.belongsTo) {
|
|
160
|
+
for (const rel of model.directives.belongsTo) {
|
|
161
|
+
const name = rel.model[0].toLowerCase() + rel.model.slice(1);
|
|
162
|
+
relations[name] = { type: 'belongsTo', model: rel.model, foreignKey: toSnakeCase(rel.model) + '_id' };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (model.directives?.hasMany) {
|
|
166
|
+
for (const rel of model.directives.hasMany) {
|
|
167
|
+
const name = pluralize(rel.model[0].toLowerCase() + rel.model.slice(1));
|
|
168
|
+
relations[name] = { type: 'hasMany', model: rel.model, foreignKey: toSnakeCase(modelName) + '_id' };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (model.directives?.hasOne) {
|
|
172
|
+
for (const rel of model.directives.hasOne) {
|
|
173
|
+
const name = rel.model[0].toLowerCase() + rel.model.slice(1);
|
|
174
|
+
relations[name] = { type: 'hasOne', model: rel.model, foreignKey: toSnakeCase(modelName) + '_id' };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
this._relations = relations;
|
|
178
|
+
this._schemaRef = schemaInstance;
|
|
179
|
+
|
|
180
|
+
// Detect primary key from fields
|
|
181
|
+
for (const [name, def] of Object.entries(fields)) {
|
|
182
|
+
if (def.primary) { this.primaryKey = name; break; }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.schema(fields);
|
|
186
|
+
return this;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Table name helper
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
static tableName() {
|
|
194
|
+
return this.database ? `"${this.database}"."${this.table}"` : `"${this.table}"`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Constructor — creates a record instance
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
constructor(data = {}, persisted = false) {
|
|
202
|
+
this._data = {};
|
|
203
|
+
this._dirty = {};
|
|
204
|
+
this._persisted = persisted;
|
|
205
|
+
|
|
206
|
+
const schema = this.constructor._schema;
|
|
207
|
+
const columns = this.constructor._columns || {};
|
|
208
|
+
|
|
209
|
+
// Apply schema defaults and initial data
|
|
210
|
+
for (const name in schema) {
|
|
211
|
+
const field = schema[name];
|
|
212
|
+
const col = columns[name] || name;
|
|
213
|
+
if (data[col] != null) {
|
|
214
|
+
this._data[col] = data[col];
|
|
215
|
+
} else if (data[name] != null) {
|
|
216
|
+
this._data[col] = data[name];
|
|
217
|
+
} else if (field.default != null) {
|
|
218
|
+
this._data[col] = typeof field.default === 'function' ? field.default() : field.default;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Define property accessors for schema fields
|
|
223
|
+
for (const name in schema) {
|
|
224
|
+
const col = columns[name] || name;
|
|
225
|
+
Object.defineProperty(this, name, {
|
|
226
|
+
enumerable: true,
|
|
227
|
+
get() { return this._data[col]; },
|
|
228
|
+
set(value) { this._data[col] = value; this._dirty[name] = true; },
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Define computed property accessors (reactive getters)
|
|
233
|
+
const computed = this.constructor._computed;
|
|
234
|
+
for (const name in computed) {
|
|
235
|
+
const fn = computed[name];
|
|
236
|
+
Object.defineProperty(this, name, {
|
|
237
|
+
enumerable: true,
|
|
238
|
+
get() { return fn.call(this); },
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Define relation methods (lazy loading, with eager cache)
|
|
243
|
+
this._eagerLoaded = null;
|
|
244
|
+
const relations = this.constructor._relations;
|
|
245
|
+
const schemaRef = this.constructor._schemaRef;
|
|
246
|
+
for (const name in relations) {
|
|
247
|
+
const rel = relations[name];
|
|
248
|
+
if (this[name] !== undefined) continue; // don't shadow schema fields (e.g., organization_id)
|
|
249
|
+
Object.defineProperty(this, name, {
|
|
250
|
+
enumerable: false,
|
|
251
|
+
value: async () => {
|
|
252
|
+
// Return eager-loaded data if available (no query)
|
|
253
|
+
if (this._eagerLoaded?.has(name)) return this._eagerLoaded.get(name);
|
|
254
|
+
// Lazy load
|
|
255
|
+
const RelModel = schemaRef._models?.get(rel.model);
|
|
256
|
+
if (!RelModel) throw new Error(`Model '${rel.model}' not registered — define it with schema.model('${rel.model}', ...)`);
|
|
257
|
+
const pk = this._data[this.constructor.primaryKey];
|
|
258
|
+
if (rel.type === 'belongsTo') {
|
|
259
|
+
const fk = this._data[rel.foreignKey];
|
|
260
|
+
return fk != null ? await RelModel.find(fk) : null;
|
|
261
|
+
} else if (rel.type === 'hasMany') {
|
|
262
|
+
return await RelModel.where({ [rel.foreignKey]: pk }).all();
|
|
263
|
+
} else if (rel.type === 'hasOne') {
|
|
264
|
+
return await RelModel.where({ [rel.foreignKey]: pk }).first();
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Instance state
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
get $isNew() { return !this._persisted; }
|
|
276
|
+
get $dirty() { return Object.keys(this._dirty); }
|
|
277
|
+
get $changed() { return Object.keys(this._dirty).length > 0; }
|
|
278
|
+
get $data() { return { ...this._data }; }
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Validation
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
$validate() {
|
|
285
|
+
const errors = [];
|
|
286
|
+
const schema = this.constructor._schema;
|
|
287
|
+
const columns = this.constructor._columns || {};
|
|
288
|
+
|
|
289
|
+
for (const name in schema) {
|
|
290
|
+
const field = schema[name];
|
|
291
|
+
const col = columns[name] || name;
|
|
292
|
+
const value = this._data[col];
|
|
293
|
+
|
|
294
|
+
// Required check
|
|
295
|
+
if (field.required && value == null) {
|
|
296
|
+
errors.push({ field: name, error: 'required', message: `${name} is required` });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (value == null) continue; // Skip optional empty fields
|
|
301
|
+
|
|
302
|
+
// Type checks
|
|
303
|
+
switch (field.type) {
|
|
304
|
+
case 'string': case 'text':
|
|
305
|
+
if (typeof value !== 'string') {
|
|
306
|
+
errors.push({ field: name, error: 'type', message: `${name} must be a string` });
|
|
307
|
+
} else {
|
|
308
|
+
if (field.min != null && value.length < field.min)
|
|
309
|
+
errors.push({ field: name, error: 'min', message: `${name} must be at least ${field.min} characters` });
|
|
310
|
+
if (field.max != null && value.length > field.max)
|
|
311
|
+
errors.push({ field: name, error: 'max', message: `${name} must be at most ${field.max} characters` });
|
|
312
|
+
if (field.regex && !field.regex.test(value))
|
|
313
|
+
errors.push({ field: name, error: 'pattern', message: `${name} is invalid` });
|
|
314
|
+
}
|
|
315
|
+
break;
|
|
316
|
+
case 'int': case 'integer':
|
|
317
|
+
if (!Number.isInteger(value)) {
|
|
318
|
+
errors.push({ field: name, error: 'type', message: `${name} must be an integer` });
|
|
319
|
+
} else {
|
|
320
|
+
if (field.min != null && value < field.min)
|
|
321
|
+
errors.push({ field: name, error: 'min', message: `${name} must be >= ${field.min}` });
|
|
322
|
+
if (field.max != null && value > field.max)
|
|
323
|
+
errors.push({ field: name, error: 'max', message: `${name} must be <= ${field.max}` });
|
|
324
|
+
}
|
|
325
|
+
break;
|
|
326
|
+
case 'bool': case 'boolean':
|
|
327
|
+
if (typeof value !== 'boolean')
|
|
328
|
+
errors.push({ field: name, error: 'type', message: `${name} must be a boolean` });
|
|
329
|
+
break;
|
|
330
|
+
case 'email':
|
|
331
|
+
if (typeof value !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
332
|
+
errors.push({ field: name, error: 'type', message: `${name} must be a valid email` });
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Enum check
|
|
337
|
+
if (field.enum && !field.enum.includes(value))
|
|
338
|
+
errors.push({ field: name, error: 'enum', message: `${name} must be one of: ${field.enum.join(', ')}` });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return errors.length > 0 ? errors : null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Lifecycle hooks
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
async _runHook(name) {
|
|
349
|
+
const fn = this.constructor._hooks[name];
|
|
350
|
+
if (fn) return await fn.call(this);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Persistence
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
async save() {
|
|
358
|
+
const isNew = !this._persisted;
|
|
359
|
+
|
|
360
|
+
// Before hooks (can normalize data, return false to abort)
|
|
361
|
+
if (await this._runHook('beforeSave') === false) return this;
|
|
362
|
+
if (isNew && await this._runHook('beforeCreate') === false) return this;
|
|
363
|
+
if (!isNew && await this._runHook('beforeUpdate') === false) return this;
|
|
364
|
+
|
|
365
|
+
// Validate (after hooks have normalized data)
|
|
366
|
+
const errors = this.$validate();
|
|
367
|
+
if (errors) throw new Error(`Validation failed: ${JSON.stringify(errors)}`);
|
|
368
|
+
|
|
369
|
+
const Ctor = this.constructor;
|
|
370
|
+
const tableName = Ctor.tableName();
|
|
371
|
+
const pk = Ctor.primaryKey;
|
|
372
|
+
const schema = Ctor._schema;
|
|
373
|
+
const columns = Ctor._columns || {};
|
|
374
|
+
|
|
375
|
+
// Serialize JSON-typed values for DuckDB
|
|
376
|
+
const _serializeValue = (val, field) => {
|
|
377
|
+
if (field && field.type === 'json' && val != null && typeof val === 'object') {
|
|
378
|
+
return JSON.stringify(val);
|
|
379
|
+
}
|
|
380
|
+
return val;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
if (this._persisted) {
|
|
384
|
+
// UPDATE — only dirty fields
|
|
385
|
+
const sets = [], values = [];
|
|
386
|
+
for (const name of this.$dirty) {
|
|
387
|
+
const col = columns[name] || name;
|
|
388
|
+
sets.push(`"${col}" = ?`);
|
|
389
|
+
values.push(_serializeValue(this._data[col], schema[name]));
|
|
390
|
+
}
|
|
391
|
+
if (sets.length === 0) return this;
|
|
392
|
+
|
|
393
|
+
const sql = `UPDATE ${tableName} SET ${sets.join(', ')} WHERE "${pk}" = ?`;
|
|
394
|
+
values.push(this._data[pk]);
|
|
395
|
+
await query(sql, values);
|
|
396
|
+
} else {
|
|
397
|
+
// INSERT
|
|
398
|
+
const cols = [], placeholders = [], values = [];
|
|
399
|
+
for (const name in schema) {
|
|
400
|
+
const field = schema[name];
|
|
401
|
+
const col = columns[name] || name;
|
|
402
|
+
if (field.primary && this._data[col] == null) continue; // Skip auto PK
|
|
403
|
+
if (this._data[col] != null) {
|
|
404
|
+
cols.push(`"${col}"`);
|
|
405
|
+
placeholders.push('?');
|
|
406
|
+
values.push(_serializeValue(this._data[col], field));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const sql = `INSERT INTO ${tableName} (${cols.join(', ')}) VALUES (${placeholders.join(', ')})`;
|
|
411
|
+
await query(sql, values);
|
|
412
|
+
this._persisted = true;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this._dirty = {};
|
|
416
|
+
|
|
417
|
+
// After hooks
|
|
418
|
+
if (isNew) await this._runHook('afterCreate');
|
|
419
|
+
else await this._runHook('afterUpdate');
|
|
420
|
+
await this._runHook('afterSave');
|
|
421
|
+
|
|
422
|
+
return this;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async delete() {
|
|
426
|
+
if (!this._persisted) return this;
|
|
427
|
+
if (await this._runHook('beforeDelete') === false) return this;
|
|
428
|
+
const Ctor = this.constructor;
|
|
429
|
+
const pk = Ctor.primaryKey;
|
|
430
|
+
await query(`DELETE FROM ${Ctor.tableName()} WHERE "${pk}" = ?`, [this._data[pk]]);
|
|
431
|
+
this._persisted = false;
|
|
432
|
+
await this._runHook('afterDelete');
|
|
433
|
+
return this;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async softDelete() {
|
|
437
|
+
if (!this._persisted) return this;
|
|
438
|
+
const Ctor = this.constructor;
|
|
439
|
+
if (!Ctor._softDelete) throw new Error(`${Ctor.name || 'Model'} does not have @softDelete`);
|
|
440
|
+
if (await this._runHook('beforeDelete') === false) return this;
|
|
441
|
+
const pk = Ctor.primaryKey;
|
|
442
|
+
const now = new Date().toISOString();
|
|
443
|
+
await query(`UPDATE ${Ctor.tableName()} SET "deleted_at" = ? WHERE "${pk}" = ?`, [now, this._data[pk]]);
|
|
444
|
+
this._data.deleted_at = now;
|
|
445
|
+
await this._runHook('afterDelete');
|
|
446
|
+
return this;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async restore() {
|
|
450
|
+
if (!this._persisted) return this;
|
|
451
|
+
const Ctor = this.constructor;
|
|
452
|
+
if (!Ctor._softDelete) throw new Error(`${Ctor.name || 'Model'} does not have @softDelete`);
|
|
453
|
+
const pk = Ctor.primaryKey;
|
|
454
|
+
await query(`UPDATE ${Ctor.tableName()} SET "deleted_at" = NULL WHERE "${pk}" = ?`, [this._data[pk]]);
|
|
455
|
+
this._data.deleted_at = null;
|
|
456
|
+
return this;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async reload() {
|
|
460
|
+
if (!this._persisted) return this;
|
|
461
|
+
const Ctor = this.constructor;
|
|
462
|
+
const pk = Ctor.primaryKey;
|
|
463
|
+
const record = await Ctor.find(this._data[pk]);
|
|
464
|
+
if (record) {
|
|
465
|
+
this._data = record._data;
|
|
466
|
+
this._dirty = {};
|
|
467
|
+
}
|
|
468
|
+
return this;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
toJSON() {
|
|
472
|
+
const obj = {};
|
|
473
|
+
for (const name in this.constructor._schema) obj[name] = this[name];
|
|
474
|
+
for (const name in this.constructor._computed) obj[name] = this[name];
|
|
475
|
+
if (this._eagerLoaded) {
|
|
476
|
+
for (const [name, val] of this._eagerLoaded) {
|
|
477
|
+
obj[name] = Array.isArray(val) ? val.map(r => r.toJSON()) : val?.toJSON() ?? null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return obj;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// Links — Universal temporal associations
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
async link(role, target, { from = null, till = null } = {}) {
|
|
488
|
+
const sourceType = this.constructor.table;
|
|
489
|
+
const sourceId = this._data[this.constructor.primaryKey];
|
|
490
|
+
const targetType = target.constructor.table;
|
|
491
|
+
const targetId = target._data[target.constructor.primaryKey];
|
|
492
|
+
const sql = `INSERT INTO "links" ("source_type", "source_id", "target_type", "target_id", "role", "when_from", "when_till") VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
|
493
|
+
await query(sql, [sourceType, sourceId, targetType, targetId, role, from, till]);
|
|
494
|
+
return this;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async unlink(role, target) {
|
|
498
|
+
const sourceType = this.constructor.table;
|
|
499
|
+
const sourceId = this._data[this.constructor.primaryKey];
|
|
500
|
+
const targetType = target.constructor.table;
|
|
501
|
+
const targetId = target._data[target.constructor.primaryKey];
|
|
502
|
+
const sql = `UPDATE "links" SET "when_till" = CURRENT_TIMESTAMP WHERE "source_type" = ? AND "source_id" = ? AND "target_type" = ? AND "target_id" = ? AND "role" = ? AND "when_till" IS NULL`;
|
|
503
|
+
await query(sql, [sourceType, sourceId, targetType, targetId, role]);
|
|
504
|
+
return this;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async links(role, { at = null } = {}) {
|
|
508
|
+
const sourceType = this.constructor.table;
|
|
509
|
+
const sourceId = this._data[this.constructor.primaryKey];
|
|
510
|
+
const params = [sourceType, sourceId];
|
|
511
|
+
let sql = `SELECT * FROM "links" WHERE "source_type" = ? AND "source_id" = ?`;
|
|
512
|
+
if (role) { sql += ` AND "role" = ?`; params.push(role); }
|
|
513
|
+
sql += _temporalFilter(at, params);
|
|
514
|
+
sql += ` ORDER BY "created_at" DESC`;
|
|
515
|
+
const result = await query(sql, params);
|
|
516
|
+
return result.data.map(row => {
|
|
517
|
+
const link = {};
|
|
518
|
+
for (let i = 0; i < result.meta.length; i++) link[_toCamelCase(result.meta[i].name)] = row[i];
|
|
519
|
+
return link;
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
static async linked(role, target, { at = null } = {}) {
|
|
524
|
+
const targetType = target.constructor ? target.constructor.table : target.table;
|
|
525
|
+
const targetId = target.constructor ? target._data[target.constructor.primaryKey] : target._data?.[target.primaryKey];
|
|
526
|
+
const sourceType = this.table;
|
|
527
|
+
const params = [sourceType, targetType, targetId, role];
|
|
528
|
+
let sql = `SELECT "source_id" FROM "links" WHERE "source_type" = ? AND "target_type" = ? AND "target_id" = ? AND "role" = ?`;
|
|
529
|
+
sql += _temporalFilter(at, params);
|
|
530
|
+
const result = await query(sql, params);
|
|
531
|
+
if (result.rows === 0) return [];
|
|
532
|
+
const ids = result.data.map(row => row[0]);
|
|
533
|
+
return this.findMany(ids);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
// Class methods — Query API
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
static _materialize(meta, row) {
|
|
541
|
+
const data = {};
|
|
542
|
+
for (let i = 0; i < meta.length; i++) data[meta[i].name] = row[i];
|
|
543
|
+
return new this(data, true);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
static async find(id) {
|
|
547
|
+
const pk = this.primaryKey;
|
|
548
|
+
const soft = this._softDelete ? ' AND "deleted_at" IS NULL' : '';
|
|
549
|
+
const sql = `SELECT * FROM ${this.tableName()} WHERE "${pk}" = ?${soft} LIMIT 1`;
|
|
550
|
+
const result = await query(sql, [id]);
|
|
551
|
+
if (result.rows === 0) return null;
|
|
552
|
+
return this._materialize(result.meta, result.data[0]);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
static async findMany(ids) {
|
|
556
|
+
if (ids.length === 0) return [];
|
|
557
|
+
const pk = this.primaryKey;
|
|
558
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
559
|
+
const soft = this._softDelete ? ' AND "deleted_at" IS NULL' : '';
|
|
560
|
+
const sql = `SELECT * FROM ${this.tableName()} WHERE "${pk}" IN (${placeholders})${soft}`;
|
|
561
|
+
const result = await query(sql, ids);
|
|
562
|
+
return result.data.map(row => this._materialize(result.meta, row));
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
static async all(limit = null) {
|
|
566
|
+
const q = new Query(this);
|
|
567
|
+
if (limit != null) q.limit(limit);
|
|
568
|
+
return q.all();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
static async first() {
|
|
572
|
+
return new Query(this).first();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
static where(conditions, ...params) {
|
|
576
|
+
return new Query(this).where(conditions, ...params);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
static withDeleted() {
|
|
580
|
+
return new Query(this, { includeDeleted: true });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
static include(...names) {
|
|
584
|
+
return new Query(this).include(...names);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
static count(conditions = null) {
|
|
588
|
+
const q = new Query(this);
|
|
589
|
+
if (conditions != null) q.where(conditions);
|
|
590
|
+
return q.count();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
static build(data = {}) {
|
|
594
|
+
return new this(data, false);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
static async create(data = {}) {
|
|
598
|
+
return await this.build(data).save();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
// Factory — schema-driven fake data generation
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// User.factory() → create 1 (persisted, single)
|
|
605
|
+
// User.factory(0) → build 1 (not persisted, single)
|
|
606
|
+
// User.factory(3) → create 3 (persisted, array)
|
|
607
|
+
// User.factory(-3) → build 3 (not persisted, array)
|
|
608
|
+
// User.factory(3, {}) → create 3 with overrides (array)
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
static _fake(overrides = {}) {
|
|
612
|
+
const data = {};
|
|
613
|
+
const schema = this._schema;
|
|
614
|
+
const schemaRef = this._schemaRef;
|
|
615
|
+
const s = this._factorySeq = (this._factorySeq || 0) + 1;
|
|
616
|
+
|
|
617
|
+
for (const name in schema) {
|
|
618
|
+
if (overrides[name] != null) { data[name] = overrides[name]; continue; }
|
|
619
|
+
|
|
620
|
+
const field = schema[name];
|
|
621
|
+
if (field.primary) continue;
|
|
622
|
+
if (name === 'created_at' || name === 'updated_at' || name === 'deleted_at') continue;
|
|
623
|
+
|
|
624
|
+
// Use schema default if available
|
|
625
|
+
if (field.default != null) {
|
|
626
|
+
data[name] = typeof field.default === 'function' ? field.default() : field.default;
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Skip optional fields sometimes (30% chance of null)
|
|
631
|
+
if (!field.required && Math.random() < 0.3) continue;
|
|
632
|
+
|
|
633
|
+
// FK fields — leave for caller to set via overrides
|
|
634
|
+
if (field.type === 'uuid') continue;
|
|
635
|
+
|
|
636
|
+
// Skip nested/composite types (e.g. Address) — can't fake a struct
|
|
637
|
+
if (schemaRef?.types?.has(field.type)) continue;
|
|
638
|
+
|
|
639
|
+
// Resolve enum values if applicable
|
|
640
|
+
const enumVals = schemaRef?.enums?.has(field.type) ? schemaRef.enums.get(field.type) : null;
|
|
641
|
+
|
|
642
|
+
data[name] = Fake.value(name, field, s, enumVals);
|
|
643
|
+
}
|
|
644
|
+
return data;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
static async factory(num, overrides = {}) {
|
|
648
|
+
// Custom faker on the model takes priority
|
|
649
|
+
const fake = (this._faker)
|
|
650
|
+
? (ov) => ({ ...this._faker(ov), ...ov })
|
|
651
|
+
: (ov) => this._fake(ov);
|
|
652
|
+
|
|
653
|
+
if (num == null) {
|
|
654
|
+
return this.create(fake(overrides));
|
|
655
|
+
} else if (num === 0) {
|
|
656
|
+
return this.build(fake(overrides));
|
|
657
|
+
} else if (num > 0) {
|
|
658
|
+
return Promise.all(Array.from({ length: num }, () => this.create(fake(overrides))));
|
|
659
|
+
} else {
|
|
660
|
+
return Array.from({ length: -num }, () => this.build(fake(overrides)));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// =============================================================================
|
|
667
|
+
// Query — Chainable query builder
|
|
668
|
+
// =============================================================================
|
|
669
|
+
|
|
670
|
+
class Query {
|
|
671
|
+
constructor(model, { includeDeleted = false } = {}) {
|
|
672
|
+
this._model = model;
|
|
673
|
+
this._where = [];
|
|
674
|
+
this._params = [];
|
|
675
|
+
this._order = null;
|
|
676
|
+
this._limit = null;
|
|
677
|
+
this._offset = null;
|
|
678
|
+
this._includes = [];
|
|
679
|
+
this._includeDeleted = includeDeleted;
|
|
680
|
+
|
|
681
|
+
// Auto-filter soft-deleted records unless explicitly included
|
|
682
|
+
if (model._softDelete && !includeDeleted) {
|
|
683
|
+
this._where.push('"deleted_at" IS NULL');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
withDeleted() {
|
|
688
|
+
// Remove the auto-added soft-delete filter
|
|
689
|
+
this._where = this._where.filter(w => w !== '"deleted_at" IS NULL');
|
|
690
|
+
this._includeDeleted = true;
|
|
691
|
+
return this;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
include(...names) {
|
|
695
|
+
this._includes.push(...names.flat());
|
|
696
|
+
return this;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Batch-load included relations (2-query strategy, eliminates N+1)
|
|
700
|
+
async _loadIncludes(records) {
|
|
701
|
+
if (this._includes.length === 0 || records.length === 0) return records;
|
|
702
|
+
|
|
703
|
+
const model = this._model;
|
|
704
|
+
const relations = model._relations;
|
|
705
|
+
const schemaRef = model._schemaRef;
|
|
706
|
+
|
|
707
|
+
for (const relName of this._includes) {
|
|
708
|
+
const rel = relations[relName];
|
|
709
|
+
if (!rel) throw new Error(`Unknown relation '${relName}' on ${model.name || 'Model'}`);
|
|
710
|
+
|
|
711
|
+
const RelModel = schemaRef._models?.get(rel.model);
|
|
712
|
+
if (!RelModel) throw new Error(`Model '${rel.model}' not registered`);
|
|
713
|
+
|
|
714
|
+
if (rel.type === 'belongsTo') {
|
|
715
|
+
const fkValues = [...new Set(records.map(r => r._data[rel.foreignKey]).filter(v => v != null))];
|
|
716
|
+
if (fkValues.length === 0) {
|
|
717
|
+
for (const r of records) { (r._eagerLoaded ||= new Map()).set(relName, null); }
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
const related = await RelModel.findMany(fkValues);
|
|
721
|
+
const byPk = new Map(related.map(r => [r._data[RelModel.primaryKey], r]));
|
|
722
|
+
for (const r of records) {
|
|
723
|
+
(r._eagerLoaded ||= new Map()).set(relName, byPk.get(r._data[rel.foreignKey]) || null);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
} else if (rel.type === 'hasMany' || rel.type === 'hasOne') {
|
|
727
|
+
const pk = model.primaryKey;
|
|
728
|
+
const pkValues = records.map(r => r._data[pk]);
|
|
729
|
+
const placeholders = pkValues.map(() => '?').join(', ');
|
|
730
|
+
const soft = RelModel._softDelete ? ' AND "deleted_at" IS NULL' : '';
|
|
731
|
+
const sql = `SELECT * FROM ${RelModel.tableName()} WHERE "${rel.foreignKey}" IN (${placeholders})${soft}`;
|
|
732
|
+
const result = await query(sql, pkValues);
|
|
733
|
+
const allRelated = result.data.map(row => RelModel._materialize(result.meta, row));
|
|
734
|
+
|
|
735
|
+
if (rel.type === 'hasMany') {
|
|
736
|
+
const byFk = new Map();
|
|
737
|
+
for (const r of allRelated) {
|
|
738
|
+
const fk = r._data[rel.foreignKey];
|
|
739
|
+
if (!byFk.has(fk)) byFk.set(fk, []);
|
|
740
|
+
byFk.get(fk).push(r);
|
|
741
|
+
}
|
|
742
|
+
for (const r of records) {
|
|
743
|
+
(r._eagerLoaded ||= new Map()).set(relName, byFk.get(r._data[pk]) || []);
|
|
744
|
+
}
|
|
745
|
+
} else {
|
|
746
|
+
const byFk = new Map();
|
|
747
|
+
for (const r of allRelated) {
|
|
748
|
+
const fk = r._data[rel.foreignKey];
|
|
749
|
+
if (!byFk.has(fk)) byFk.set(fk, r); // first match wins
|
|
750
|
+
}
|
|
751
|
+
for (const r of records) {
|
|
752
|
+
(r._eagerLoaded ||= new Map()).set(relName, byFk.get(r._data[pk]) || null);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return records;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
where(conditions, ...params) {
|
|
761
|
+
if (typeof conditions === 'string') {
|
|
762
|
+
this._where.push(conditions);
|
|
763
|
+
this._params.push(...params);
|
|
764
|
+
} else if (typeof conditions === 'object') {
|
|
765
|
+
const columns = this._model._columns || {};
|
|
766
|
+
for (const key in conditions) {
|
|
767
|
+
const col = columns[key] || key;
|
|
768
|
+
this._where.push(`"${col}" = ?`);
|
|
769
|
+
this._params.push(conditions[key]);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return this;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
orderBy(column, direction = 'ASC') {
|
|
776
|
+
const columns = this._model._columns || {};
|
|
777
|
+
const col = columns[column] || column;
|
|
778
|
+
this._order = `"${col}" ${direction.toUpperCase()}`;
|
|
779
|
+
return this;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
limit(n) {
|
|
783
|
+
this._limit = n;
|
|
784
|
+
return this;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
offset(n) {
|
|
788
|
+
this._offset = n;
|
|
789
|
+
return this;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
toSQL() {
|
|
793
|
+
let sql = `SELECT * FROM ${this._model.tableName()}`;
|
|
794
|
+
if (this._where.length > 0) sql += ` WHERE ${this._where.join(' AND ')}`;
|
|
795
|
+
if (this._order) sql += ` ORDER BY ${this._order}`;
|
|
796
|
+
if (this._limit != null) sql += ` LIMIT ${this._limit}`;
|
|
797
|
+
if (this._offset != null) sql += ` OFFSET ${this._offset}`;
|
|
798
|
+
return { sql, params: this._params };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async all() {
|
|
802
|
+
const { sql, params } = this.toSQL();
|
|
803
|
+
const result = await query(sql, params);
|
|
804
|
+
const records = result.data.map(row => this._model._materialize(result.meta, row));
|
|
805
|
+
return this._loadIncludes(records);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async first() {
|
|
809
|
+
const rows = await this.limit(1).all();
|
|
810
|
+
return rows[0] || null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async count() {
|
|
814
|
+
let sql = `SELECT COUNT(*) FROM ${this._model.tableName()}`;
|
|
815
|
+
if (this._where.length > 0) sql += ` WHERE ${this._where.join(' AND ')}`;
|
|
816
|
+
const result = await query(sql, this._params);
|
|
817
|
+
return result.data[0][0];
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// =============================================================================
|
|
822
|
+
// makeCallable — User(25) → User.find(25)
|
|
823
|
+
// =============================================================================
|
|
824
|
+
|
|
825
|
+
export function makeCallable(ModelClass) {
|
|
826
|
+
const callable = function(idOrIds) {
|
|
827
|
+
if (Array.isArray(idOrIds)) {
|
|
828
|
+
return idOrIds.length === 0 ? ModelClass.all() : ModelClass.findMany(idOrIds);
|
|
829
|
+
}
|
|
830
|
+
return idOrIds != null ? ModelClass.find(idOrIds) : ModelClass.all();
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
// Copy static methods and properties (walk prototype chain to include inherited)
|
|
834
|
+
const seen = new Set(['prototype', 'length', 'name']);
|
|
835
|
+
let cls = ModelClass;
|
|
836
|
+
while (cls && cls !== Function.prototype) {
|
|
837
|
+
for (const key of Object.getOwnPropertyNames(cls)) {
|
|
838
|
+
if (seen.has(key)) continue;
|
|
839
|
+
seen.add(key);
|
|
840
|
+
const desc = Object.getOwnPropertyDescriptor(cls, key);
|
|
841
|
+
if (typeof desc.value === 'function') {
|
|
842
|
+
callable[key] = desc.value.bind(ModelClass);
|
|
843
|
+
} else if (desc) {
|
|
844
|
+
Object.defineProperty(callable, key, desc);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
cls = Object.getPrototypeOf(cls);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return callable;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// =============================================================================
|
|
854
|
+
// Extend Schema with ORM capabilities
|
|
855
|
+
// =============================================================================
|
|
856
|
+
|
|
857
|
+
Schema.prototype.connect = function(url) {
|
|
858
|
+
_dbUrl = url;
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
Schema.prototype.model = function(modelName, options = {}) {
|
|
862
|
+
if (!this._models) this._models = new Map();
|
|
863
|
+
|
|
864
|
+
const HOOKS = new Set([
|
|
865
|
+
'beforeSave', 'afterSave', 'beforeCreate', 'afterCreate',
|
|
866
|
+
'beforeUpdate', 'afterUpdate', 'beforeDelete', 'afterDelete',
|
|
867
|
+
]);
|
|
868
|
+
const { computed: computedDefs, faker: fakerFn, ...rest } = options;
|
|
869
|
+
|
|
870
|
+
// Separate hooks from instance methods
|
|
871
|
+
const hooks = {}, methods = {};
|
|
872
|
+
for (const [name, fn] of Object.entries(rest)) {
|
|
873
|
+
if (HOOKS.has(name)) hooks[name] = fn; else methods[name] = fn;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Create a new class extending Model
|
|
877
|
+
const ModelClass = class extends Model {};
|
|
878
|
+
|
|
879
|
+
// Add instance methods to the prototype
|
|
880
|
+
for (const [name, fn] of Object.entries(methods)) {
|
|
881
|
+
ModelClass.prototype[name] = fn;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Load schema (table, fields, types, constraints, timestamps, FKs, relations)
|
|
885
|
+
ModelClass.fromSchema(this, modelName);
|
|
886
|
+
|
|
887
|
+
// Add computed properties (getters, no parens needed)
|
|
888
|
+
if (computedDefs) ModelClass.computed(computedDefs);
|
|
889
|
+
|
|
890
|
+
// Set custom faker if provided
|
|
891
|
+
if (fakerFn) ModelClass._faker = fakerFn;
|
|
892
|
+
|
|
893
|
+
// Register lifecycle hooks
|
|
894
|
+
ModelClass._hooks = hooks;
|
|
895
|
+
|
|
896
|
+
// Return callable and register in schema for relation lookups
|
|
897
|
+
const callable = makeCallable(ModelClass);
|
|
898
|
+
this._models.set(modelName, callable);
|
|
899
|
+
return callable;
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
// ---------------------------------------------------------------------------
|
|
903
|
+
// Transactions — atomic multi-model operations
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
|
|
906
|
+
Schema.prototype.transaction = async function(fn) {
|
|
907
|
+
await query('BEGIN TRANSACTION');
|
|
908
|
+
try {
|
|
909
|
+
const result = await fn();
|
|
910
|
+
await query('COMMIT');
|
|
911
|
+
return result;
|
|
912
|
+
} catch (err) {
|
|
913
|
+
await query('ROLLBACK');
|
|
914
|
+
throw err;
|
|
915
|
+
}
|
|
916
|
+
};
|