zero-http 0.2.5 → 0.3.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 +1250 -283
- package/documentation/config/db.js +25 -0
- package/documentation/config/middleware.js +44 -0
- package/documentation/config/tls.js +12 -0
- package/documentation/controllers/cookies.js +34 -0
- package/documentation/controllers/tasks.js +108 -0
- package/documentation/full-server.js +25 -184
- package/documentation/models/Task.js +21 -0
- package/documentation/public/data/api.json +404 -24
- package/documentation/public/data/docs.json +1139 -0
- package/documentation/public/data/examples.json +80 -2
- package/documentation/public/data/options.json +23 -8
- package/documentation/public/index.html +138 -99
- package/documentation/public/scripts/app.js +1 -3
- package/documentation/public/scripts/custom-select.js +189 -0
- package/documentation/public/scripts/data-sections.js +233 -250
- package/documentation/public/scripts/playground.js +270 -0
- package/documentation/public/scripts/ui.js +4 -3
- package/documentation/public/styles.css +56 -5
- package/documentation/public/vendor/icons/compress.svg +17 -17
- package/documentation/public/vendor/icons/database.svg +21 -0
- package/documentation/public/vendor/icons/env.svg +21 -0
- package/documentation/public/vendor/icons/fetch.svg +11 -14
- package/documentation/public/vendor/icons/security.svg +15 -0
- package/documentation/public/vendor/icons/sse.svg +12 -13
- package/documentation/public/vendor/icons/static.svg +12 -26
- package/documentation/public/vendor/icons/stream.svg +7 -13
- package/documentation/public/vendor/icons/validate.svg +17 -0
- package/documentation/routes/api.js +41 -0
- package/documentation/routes/core.js +20 -0
- package/documentation/routes/playground.js +29 -0
- package/documentation/routes/realtime.js +49 -0
- package/documentation/routes/uploads.js +71 -0
- package/index.js +62 -1
- package/lib/app.js +200 -8
- package/lib/body/json.js +28 -5
- package/lib/body/multipart.js +29 -1
- package/lib/body/raw.js +1 -1
- package/lib/body/sendError.js +1 -0
- package/lib/body/text.js +1 -1
- package/lib/body/typeMatch.js +6 -2
- package/lib/body/urlencoded.js +5 -2
- package/lib/debug.js +345 -0
- package/lib/env/index.js +440 -0
- package/lib/errors.js +231 -0
- package/lib/http/request.js +219 -1
- package/lib/http/response.js +410 -6
- package/lib/middleware/compress.js +39 -6
- package/lib/middleware/cookieParser.js +237 -0
- package/lib/middleware/cors.js +13 -2
- package/lib/middleware/csrf.js +135 -0
- package/lib/middleware/errorHandler.js +90 -0
- package/lib/middleware/helmet.js +176 -0
- package/lib/middleware/index.js +7 -2
- package/lib/middleware/rateLimit.js +12 -1
- package/lib/middleware/requestId.js +54 -0
- package/lib/middleware/static.js +95 -11
- package/lib/middleware/timeout.js +72 -0
- package/lib/middleware/validator.js +257 -0
- package/lib/orm/adapters/json.js +215 -0
- package/lib/orm/adapters/memory.js +383 -0
- package/lib/orm/adapters/mongo.js +444 -0
- package/lib/orm/adapters/mysql.js +272 -0
- package/lib/orm/adapters/postgres.js +394 -0
- package/lib/orm/adapters/sql-base.js +142 -0
- package/lib/orm/adapters/sqlite.js +311 -0
- package/lib/orm/index.js +276 -0
- package/lib/orm/model.js +895 -0
- package/lib/orm/query.js +807 -0
- package/lib/orm/schema.js +172 -0
- package/lib/router/index.js +136 -47
- package/lib/sse/stream.js +15 -3
- package/lib/ws/connection.js +19 -3
- package/lib/ws/handshake.js +3 -0
- package/lib/ws/index.js +3 -1
- package/lib/ws/room.js +222 -0
- package/package.json +15 -5
- package/types/app.d.ts +120 -0
- package/types/env.d.ts +80 -0
- package/types/errors.d.ts +147 -0
- package/types/fetch.d.ts +43 -0
- package/types/index.d.ts +135 -0
- package/types/middleware.d.ts +292 -0
- package/types/orm.d.ts +610 -0
- package/types/request.d.ts +99 -0
- package/types/response.d.ts +142 -0
- package/types/router.d.ts +78 -0
- package/types/sse.d.ts +78 -0
- package/types/websocket.d.ts +119 -0
package/lib/orm/model.js
ADDED
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module orm/model
|
|
3
|
+
* @description Base Model class for defining database-backed entities.
|
|
4
|
+
* Provides static CRUD methods, instance-level save/update/delete,
|
|
5
|
+
* lifecycle hooks, and relationship definitions.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { Model, Database } = require('zero-http');
|
|
9
|
+
*
|
|
10
|
+
* class User extends Model {
|
|
11
|
+
* static table = 'users';
|
|
12
|
+
* static schema = {
|
|
13
|
+
* id: { type: 'integer', primaryKey: true, autoIncrement: true },
|
|
14
|
+
* name: { type: 'string', required: true, maxLength: 100 },
|
|
15
|
+
* email: { type: 'string', required: true, unique: true },
|
|
16
|
+
* role: { type: 'string', enum: ['user','admin'], default: 'user' },
|
|
17
|
+
* };
|
|
18
|
+
* static timestamps = true; // auto createdAt/updatedAt
|
|
19
|
+
* static softDelete = true; // deletedAt instead of real delete
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* db.register(User);
|
|
23
|
+
*
|
|
24
|
+
* const user = await User.create({ name: 'Alice', email: 'a@b.com' });
|
|
25
|
+
* const users = await User.find({ role: 'admin' });
|
|
26
|
+
* const u = await User.findById(1);
|
|
27
|
+
* await u.update({ name: 'Alice2' });
|
|
28
|
+
* await u.delete();
|
|
29
|
+
*/
|
|
30
|
+
const { validate } = require('./schema');
|
|
31
|
+
const Query = require('./query');
|
|
32
|
+
const crypto = require('crypto');
|
|
33
|
+
const log = require('../debug')('zero:orm');
|
|
34
|
+
|
|
35
|
+
class Model
|
|
36
|
+
{
|
|
37
|
+
/**
|
|
38
|
+
* Table name — override in subclass.
|
|
39
|
+
* @type {string}
|
|
40
|
+
*/
|
|
41
|
+
static table = '';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Column schema — override in subclass.
|
|
45
|
+
* @type {Object<string, object>}
|
|
46
|
+
*/
|
|
47
|
+
static schema = {};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Enable auto timestamps (createdAt, updatedAt).
|
|
51
|
+
* @type {boolean}
|
|
52
|
+
*/
|
|
53
|
+
static timestamps = false;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Enable soft deletes (deletedAt instead of real deletion).
|
|
57
|
+
* @type {boolean}
|
|
58
|
+
*/
|
|
59
|
+
static softDelete = false;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fields to hide from toJSON() serialization.
|
|
63
|
+
* Useful for excluding passwords, tokens, internal fields.
|
|
64
|
+
* @type {string[]}
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* class User extends Model {
|
|
68
|
+
* static hidden = ['password', 'resetToken'];
|
|
69
|
+
* }
|
|
70
|
+
*/
|
|
71
|
+
static hidden = [];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Named query scopes — reusable query conditions.
|
|
75
|
+
* Each scope is a function that receives a Query and returns it.
|
|
76
|
+
* @type {Object<string, Function>}
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* class User extends Model {
|
|
80
|
+
* static scopes = {
|
|
81
|
+
* active: q => q.where('active', true),
|
|
82
|
+
* admins: q => q.where('role', 'admin'),
|
|
83
|
+
* olderThan: (q, age) => q.where('age', '>', age),
|
|
84
|
+
* };
|
|
85
|
+
* }
|
|
86
|
+
*
|
|
87
|
+
* // Use:
|
|
88
|
+
* await User.scope('active').scope('admins').limit(5);
|
|
89
|
+
* await User.scope('olderThan', 30);
|
|
90
|
+
*/
|
|
91
|
+
static scopes = {};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Lifecycle hooks.
|
|
95
|
+
* Override these in subclasses: `static beforeCreate(data) { return data; }`
|
|
96
|
+
* @type {object}
|
|
97
|
+
*/
|
|
98
|
+
static hooks = {};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Relationship definitions.
|
|
102
|
+
* @type {object}
|
|
103
|
+
* @private
|
|
104
|
+
*/
|
|
105
|
+
static _relations = {};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Database adapter reference — set by Database.register().
|
|
109
|
+
* @type {object|null}
|
|
110
|
+
* @private
|
|
111
|
+
*/
|
|
112
|
+
static _adapter = null;
|
|
113
|
+
|
|
114
|
+
// -- Constructor ------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a model instance from a data row.
|
|
118
|
+
* Generally you won't call this directly — use static methods.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} data - Row data.
|
|
121
|
+
*/
|
|
122
|
+
constructor(data = {})
|
|
123
|
+
{
|
|
124
|
+
/** @type {boolean} Whether this instance exists in the database. */
|
|
125
|
+
this._persisted = false;
|
|
126
|
+
|
|
127
|
+
/** @type {object} The original data snapshot for dirty tracking. */
|
|
128
|
+
this._original = {};
|
|
129
|
+
|
|
130
|
+
// Assign data to instance
|
|
131
|
+
Object.assign(this, data);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// -- Instance Methods -------------------------------
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Save this instance to the database. Insert if new, update if persisted.
|
|
138
|
+
* @returns {Promise<Model>} `this`
|
|
139
|
+
*/
|
|
140
|
+
async save()
|
|
141
|
+
{
|
|
142
|
+
const ctor = this.constructor;
|
|
143
|
+
if (this._persisted)
|
|
144
|
+
{
|
|
145
|
+
const pk = ctor._primaryKey();
|
|
146
|
+
const changes = this._dirtyFields();
|
|
147
|
+
if (Object.keys(changes).length === 0) return this;
|
|
148
|
+
|
|
149
|
+
if (ctor.timestamps && ctor._fullSchema().updatedAt)
|
|
150
|
+
{
|
|
151
|
+
changes.updatedAt = new Date();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await ctor._runHook('beforeUpdate', changes);
|
|
155
|
+
const { valid, errors, sanitized } = validate(changes, ctor._fullSchema(), { partial: true });
|
|
156
|
+
if (!valid) throw new Error('Validation failed: ' + errors.join(', '));
|
|
157
|
+
|
|
158
|
+
try { await ctor._adapter.update(ctor.table, pk, this[pk], sanitized); }
|
|
159
|
+
catch (e) { log.error('%s update failed: %s', ctor.table, e.message); throw e; }
|
|
160
|
+
log.debug('%s update id=%s', ctor.table, this[pk]);
|
|
161
|
+
Object.assign(this, sanitized);
|
|
162
|
+
await ctor._runHook('afterUpdate', this);
|
|
163
|
+
this._snapshot();
|
|
164
|
+
}
|
|
165
|
+
else
|
|
166
|
+
{
|
|
167
|
+
const data = this._toData();
|
|
168
|
+
|
|
169
|
+
if (ctor.timestamps)
|
|
170
|
+
{
|
|
171
|
+
const now = new Date();
|
|
172
|
+
if (ctor._fullSchema().createdAt && !data.createdAt) data.createdAt = now;
|
|
173
|
+
if (ctor._fullSchema().updatedAt && !data.updatedAt) data.updatedAt = now;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await ctor._runHook('beforeCreate', data);
|
|
177
|
+
const { valid, errors, sanitized } = validate(data, ctor._fullSchema());
|
|
178
|
+
if (!valid) throw new Error('Validation failed: ' + errors.join(', '));
|
|
179
|
+
|
|
180
|
+
let result;
|
|
181
|
+
try { result = await ctor._adapter.insert(ctor.table, sanitized); }
|
|
182
|
+
catch (e) { log.error('%s insert failed: %s', ctor.table, e.message); throw e; }
|
|
183
|
+
log.debug('%s insert', ctor.table);
|
|
184
|
+
const pk = ctor._primaryKey();
|
|
185
|
+
if (result && result[pk] !== undefined) this[pk] = result[pk];
|
|
186
|
+
Object.assign(this, sanitized);
|
|
187
|
+
this._persisted = true;
|
|
188
|
+
await ctor._runHook('afterCreate', this);
|
|
189
|
+
this._snapshot();
|
|
190
|
+
}
|
|
191
|
+
return this;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Update specific fields on this instance.
|
|
196
|
+
* @param {object} data - Fields to update.
|
|
197
|
+
* @returns {Promise<Model>} `this`
|
|
198
|
+
*/
|
|
199
|
+
async update(data)
|
|
200
|
+
{
|
|
201
|
+
Object.assign(this, data);
|
|
202
|
+
return this.save();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Delete this instance from the database.
|
|
207
|
+
* If softDelete is enabled, sets deletedAt instead.
|
|
208
|
+
* @returns {Promise<void>}
|
|
209
|
+
*/
|
|
210
|
+
async delete()
|
|
211
|
+
{
|
|
212
|
+
const ctor = this.constructor;
|
|
213
|
+
const pk = ctor._primaryKey();
|
|
214
|
+
|
|
215
|
+
await ctor._runHook('beforeDelete', this);
|
|
216
|
+
|
|
217
|
+
if (ctor.softDelete)
|
|
218
|
+
{
|
|
219
|
+
this.deletedAt = new Date();
|
|
220
|
+
try { await ctor._adapter.update(ctor.table, pk, this[pk], { deletedAt: this.deletedAt }); }
|
|
221
|
+
catch (e) { log.error('%s soft-delete failed: %s', ctor.table, e.message); throw e; }
|
|
222
|
+
}
|
|
223
|
+
else
|
|
224
|
+
{
|
|
225
|
+
try { await ctor._adapter.remove(ctor.table, pk, this[pk]); }
|
|
226
|
+
catch (e) { log.error('%s delete failed: %s', ctor.table, e.message); throw e; }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
log.debug('%s delete id=%s', ctor.table, this[pk]);
|
|
230
|
+
|
|
231
|
+
await ctor._runHook('afterDelete', this);
|
|
232
|
+
this._persisted = false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Restore a soft-deleted record.
|
|
237
|
+
* @returns {Promise<Model>} `this`
|
|
238
|
+
*/
|
|
239
|
+
async restore()
|
|
240
|
+
{
|
|
241
|
+
const ctor = this.constructor;
|
|
242
|
+
if (!ctor.softDelete) throw new Error('Model does not use soft deletes');
|
|
243
|
+
const pk = ctor._primaryKey();
|
|
244
|
+
this.deletedAt = null;
|
|
245
|
+
try { await ctor._adapter.update(ctor.table, pk, this[pk], { deletedAt: null }); }
|
|
246
|
+
catch (e) { log.error('%s restore failed: %s', ctor.table, e.message); throw e; }
|
|
247
|
+
return this;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Increment a numeric field atomically.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} field - Column name to increment.
|
|
254
|
+
* @param {number} [by=1] - Amount to increment by.
|
|
255
|
+
* @returns {Promise<Model>} `this`
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* await post.increment('views');
|
|
259
|
+
* await product.increment('stock', 10);
|
|
260
|
+
*/
|
|
261
|
+
async increment(field, by = 1)
|
|
262
|
+
{
|
|
263
|
+
const ctor = this.constructor;
|
|
264
|
+
const pk = ctor._primaryKey();
|
|
265
|
+
this[field] = (Number(this[field]) || 0) + by;
|
|
266
|
+
const update = { [field]: this[field] };
|
|
267
|
+
if (ctor.timestamps && ctor._fullSchema().updatedAt)
|
|
268
|
+
{
|
|
269
|
+
update.updatedAt = new Date();
|
|
270
|
+
this.updatedAt = update.updatedAt;
|
|
271
|
+
}
|
|
272
|
+
await ctor._adapter.update(ctor.table, pk, this[pk], update);
|
|
273
|
+
log.debug('%s increment %s by %d', ctor.table, field, by);
|
|
274
|
+
this._snapshot();
|
|
275
|
+
return this;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Decrement a numeric field atomically.
|
|
280
|
+
*
|
|
281
|
+
* @param {string} field - Column name to decrement.
|
|
282
|
+
* @param {number} [by=1] - Amount to decrement by.
|
|
283
|
+
* @returns {Promise<Model>} `this`
|
|
284
|
+
*
|
|
285
|
+
* @example
|
|
286
|
+
* await product.decrement('stock');
|
|
287
|
+
* await account.decrement('balance', 50);
|
|
288
|
+
*/
|
|
289
|
+
async decrement(field, by = 1)
|
|
290
|
+
{
|
|
291
|
+
return this.increment(field, -by);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Reload this instance from the database.
|
|
296
|
+
* @returns {Promise<Model>} `this`
|
|
297
|
+
*/
|
|
298
|
+
async reload()
|
|
299
|
+
{
|
|
300
|
+
const ctor = this.constructor;
|
|
301
|
+
const pk = ctor._primaryKey();
|
|
302
|
+
const fresh = await ctor.findById(this[pk]);
|
|
303
|
+
if (!fresh) throw new Error('Record not found');
|
|
304
|
+
Object.assign(this, fresh);
|
|
305
|
+
this._snapshot();
|
|
306
|
+
return this;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Convert to plain object (for JSON serialization).
|
|
311
|
+
* Respects `static hidden = [...]` to exclude sensitive fields.
|
|
312
|
+
* @returns {object}
|
|
313
|
+
*/
|
|
314
|
+
toJSON()
|
|
315
|
+
{
|
|
316
|
+
const data = {};
|
|
317
|
+
const schema = this.constructor._fullSchema();
|
|
318
|
+
const hidden = this.constructor.hidden || [];
|
|
319
|
+
for (const key of Object.keys(schema))
|
|
320
|
+
{
|
|
321
|
+
if (this[key] !== undefined && !hidden.includes(key)) data[key] = this[key];
|
|
322
|
+
}
|
|
323
|
+
return data;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// -- Internal Instance Helpers ----------------------
|
|
327
|
+
|
|
328
|
+
/** @private Snapshot current data for dirty tracking. */
|
|
329
|
+
_snapshot()
|
|
330
|
+
{
|
|
331
|
+
this._original = { ...this._toData() };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** @private Get only data columns (exclude internal props). */
|
|
335
|
+
_toData()
|
|
336
|
+
{
|
|
337
|
+
const data = {};
|
|
338
|
+
const schema = this.constructor._fullSchema();
|
|
339
|
+
for (const key of Object.keys(schema))
|
|
340
|
+
{
|
|
341
|
+
if (this[key] !== undefined) data[key] = this[key];
|
|
342
|
+
}
|
|
343
|
+
return data;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** @private Get fields that changed since last snapshot. */
|
|
347
|
+
_dirtyFields()
|
|
348
|
+
{
|
|
349
|
+
const data = this._toData();
|
|
350
|
+
const changes = {};
|
|
351
|
+
for (const [k, v] of Object.entries(data))
|
|
352
|
+
{
|
|
353
|
+
if (v !== this._original[k]) changes[k] = v;
|
|
354
|
+
}
|
|
355
|
+
return changes;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// -- Static CRUD ------------------------------------
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Create and persist a new record.
|
|
362
|
+
*
|
|
363
|
+
* @param {object} data - Record data.
|
|
364
|
+
* @returns {Promise<Model>} The created instance.
|
|
365
|
+
*/
|
|
366
|
+
static async create(data)
|
|
367
|
+
{
|
|
368
|
+
const instance = new this(data);
|
|
369
|
+
return instance.save();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Create multiple records at once.
|
|
374
|
+
*
|
|
375
|
+
* @param {object[]} dataArray - Array of record data.
|
|
376
|
+
* @returns {Promise<Model[]>}
|
|
377
|
+
*/
|
|
378
|
+
static async createMany(dataArray)
|
|
379
|
+
{
|
|
380
|
+
return Promise.all(dataArray.map(d => this.create(d)));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Find records matching conditions.
|
|
385
|
+
*
|
|
386
|
+
* @param {object} [conditions={}] - WHERE conditions `{ key: value }`.
|
|
387
|
+
* @returns {Promise<Model[]>}
|
|
388
|
+
*/
|
|
389
|
+
static async find(conditions = {})
|
|
390
|
+
{
|
|
391
|
+
const q = this.query().where(conditions);
|
|
392
|
+
return q.exec();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Find a single record matching conditions.
|
|
397
|
+
*
|
|
398
|
+
* @param {object} conditions - WHERE conditions.
|
|
399
|
+
* @returns {Promise<Model|null>}
|
|
400
|
+
*/
|
|
401
|
+
static async findOne(conditions)
|
|
402
|
+
{
|
|
403
|
+
return this.query().where(conditions).first();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Find a record by primary key.
|
|
408
|
+
*
|
|
409
|
+
* @param {*} id - Primary key value.
|
|
410
|
+
* @returns {Promise<Model|null>}
|
|
411
|
+
*/
|
|
412
|
+
static async findById(id)
|
|
413
|
+
{
|
|
414
|
+
const pk = this._primaryKey();
|
|
415
|
+
return this.query().where(pk, id).first();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Find one or create if not found.
|
|
420
|
+
*
|
|
421
|
+
* @param {object} conditions - Search conditions.
|
|
422
|
+
* @param {object} [defaults={}] - Additional data for creation.
|
|
423
|
+
* @returns {Promise<{ instance: Model, created: boolean }>}
|
|
424
|
+
*/
|
|
425
|
+
static async findOrCreate(conditions, defaults = {})
|
|
426
|
+
{
|
|
427
|
+
const existing = await this.findOne(conditions);
|
|
428
|
+
if (existing) return { instance: existing, created: false };
|
|
429
|
+
const instance = await this.create({ ...conditions, ...defaults });
|
|
430
|
+
return { instance, created: true };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Update records matching conditions.
|
|
435
|
+
*
|
|
436
|
+
* @param {object} conditions - WHERE conditions.
|
|
437
|
+
* @param {object} data - Fields to update.
|
|
438
|
+
* @returns {Promise<number>} Number of updated records.
|
|
439
|
+
*/
|
|
440
|
+
static async updateWhere(conditions, data)
|
|
441
|
+
{
|
|
442
|
+
if (this.timestamps && this._fullSchema().updatedAt)
|
|
443
|
+
{
|
|
444
|
+
data.updatedAt = new Date();
|
|
445
|
+
}
|
|
446
|
+
await this._runHook('beforeUpdate', data);
|
|
447
|
+
try { return await this._adapter.updateWhere(this.table, conditions, data); }
|
|
448
|
+
catch (e) { log.error('%s updateWhere failed: %s', this.table, e.message); throw e; }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Delete records matching conditions.
|
|
453
|
+
*
|
|
454
|
+
* @param {object} conditions - WHERE conditions.
|
|
455
|
+
* @returns {Promise<number>} Number of deleted records.
|
|
456
|
+
*/
|
|
457
|
+
static async deleteWhere(conditions)
|
|
458
|
+
{
|
|
459
|
+
if (this.softDelete)
|
|
460
|
+
{
|
|
461
|
+
try { return await this._adapter.updateWhere(this.table, conditions, { deletedAt: new Date() }); }
|
|
462
|
+
catch (e) { log.error('%s deleteWhere (soft) failed: %s', this.table, e.message); throw e; }
|
|
463
|
+
}
|
|
464
|
+
try { return await this._adapter.deleteWhere(this.table, conditions); }
|
|
465
|
+
catch (e) { log.error('%s deleteWhere failed: %s', this.table, e.message); throw e; }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Count records matching conditions.
|
|
470
|
+
*
|
|
471
|
+
* @param {object} [conditions={}] - WHERE conditions.
|
|
472
|
+
* @returns {Promise<number>}
|
|
473
|
+
*/
|
|
474
|
+
static async count(conditions = {})
|
|
475
|
+
{
|
|
476
|
+
return this.query().where(conditions).count();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Check whether any records matching conditions exist.
|
|
481
|
+
*
|
|
482
|
+
* @param {object} [conditions={}] - WHERE conditions.
|
|
483
|
+
* @returns {Promise<boolean>}
|
|
484
|
+
*
|
|
485
|
+
* @example
|
|
486
|
+
* if (await User.exists({ email: 'a@b.com' })) { ... }
|
|
487
|
+
*/
|
|
488
|
+
static async exists(conditions = {})
|
|
489
|
+
{
|
|
490
|
+
return this.query().where(conditions).exists();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Insert or update a record matching conditions.
|
|
495
|
+
* If a matching record exists, update it. Otherwise, create a new one.
|
|
496
|
+
*
|
|
497
|
+
* @param {object} conditions - Search conditions (unique fields).
|
|
498
|
+
* @param {object} data - Data to set (merged with conditions on create).
|
|
499
|
+
* @returns {Promise<{ instance: Model, created: boolean }>}
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* const { instance, created } = await User.upsert(
|
|
503
|
+
* { email: 'a@b.com' },
|
|
504
|
+
* { name: 'Alice', role: 'admin' }
|
|
505
|
+
* );
|
|
506
|
+
*/
|
|
507
|
+
static async upsert(conditions, data = {})
|
|
508
|
+
{
|
|
509
|
+
const existing = await this.findOne(conditions);
|
|
510
|
+
if (existing)
|
|
511
|
+
{
|
|
512
|
+
await existing.update(data);
|
|
513
|
+
return { instance: existing, created: false };
|
|
514
|
+
}
|
|
515
|
+
const instance = await this.create({ ...conditions, ...data });
|
|
516
|
+
return { instance, created: true };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Start a query with a named scope applied.
|
|
521
|
+
*
|
|
522
|
+
* @param {string} name - Scope name (from `static scopes`).
|
|
523
|
+
* @param {...*} [args] - Additional arguments passed to the scope function.
|
|
524
|
+
* @returns {Query}
|
|
525
|
+
*
|
|
526
|
+
* @example
|
|
527
|
+
* await User.scope('active').where('role', 'admin');
|
|
528
|
+
* await User.scope('olderThan', 21).limit(10);
|
|
529
|
+
*/
|
|
530
|
+
static scope(name, ...args)
|
|
531
|
+
{
|
|
532
|
+
if (!this.scopes || typeof this.scopes[name] !== 'function')
|
|
533
|
+
{
|
|
534
|
+
throw new Error(`Unknown scope "${name}" on ${this.name}`);
|
|
535
|
+
}
|
|
536
|
+
const q = this.query();
|
|
537
|
+
this.scopes[name](q, ...args);
|
|
538
|
+
return q;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Start a fluent query builder.
|
|
543
|
+
*
|
|
544
|
+
* @returns {Query}
|
|
545
|
+
*
|
|
546
|
+
* @example
|
|
547
|
+
* const results = await User.query()
|
|
548
|
+
* .where('age', '>', 18)
|
|
549
|
+
* .orderBy('name')
|
|
550
|
+
* .limit(10);
|
|
551
|
+
*/
|
|
552
|
+
static query()
|
|
553
|
+
{
|
|
554
|
+
if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
|
|
555
|
+
const q = new Query(this, this._adapter);
|
|
556
|
+
|
|
557
|
+
// Auto-exclude soft-deleted records
|
|
558
|
+
if (this.softDelete)
|
|
559
|
+
{
|
|
560
|
+
q.whereNull('deletedAt');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return q;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// -- LINQ-Inspired Static Shortcuts -----------------
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Find the first record matching optional conditions.
|
|
570
|
+
*
|
|
571
|
+
* @param {object} [conditions={}] - WHERE conditions.
|
|
572
|
+
* @returns {Promise<Model|null>}
|
|
573
|
+
*
|
|
574
|
+
* @example
|
|
575
|
+
* const admin = await User.first({ role: 'admin' });
|
|
576
|
+
* const oldest = await User.first(); // first by PK
|
|
577
|
+
*/
|
|
578
|
+
static async first(conditions = {})
|
|
579
|
+
{
|
|
580
|
+
return this.query().where(conditions).first();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Find the last record matching optional conditions.
|
|
585
|
+
*
|
|
586
|
+
* @param {object} [conditions={}] - WHERE conditions.
|
|
587
|
+
* @returns {Promise<Model|null>}
|
|
588
|
+
*
|
|
589
|
+
* @example
|
|
590
|
+
* const newest = await User.last();
|
|
591
|
+
* const lastAdmin = await User.last({ role: 'admin' });
|
|
592
|
+
*/
|
|
593
|
+
static async last(conditions = {})
|
|
594
|
+
{
|
|
595
|
+
return this.query().where(conditions).last();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Rich pagination with metadata.
|
|
600
|
+
* Returns `{ data, total, page, perPage, pages, hasNext, hasPrev }`.
|
|
601
|
+
*
|
|
602
|
+
* @param {number} page - 1-indexed page number.
|
|
603
|
+
* @param {number} [perPage=20] - Items per page.
|
|
604
|
+
* @param {object} [conditions={}] - Optional WHERE conditions.
|
|
605
|
+
* @returns {Promise<object>}
|
|
606
|
+
*
|
|
607
|
+
* @example
|
|
608
|
+
* const result = await User.paginate(2, 10, { role: 'admin' });
|
|
609
|
+
* // { data: [...], total: 53, page: 2, perPage: 10,
|
|
610
|
+
* // pages: 6, hasNext: true, hasPrev: true }
|
|
611
|
+
*/
|
|
612
|
+
static async paginate(page, perPage = 20, conditions = {})
|
|
613
|
+
{
|
|
614
|
+
return this.query().where(conditions).paginate(page, perPage);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Process all matching records in batches.
|
|
619
|
+
* Calls `fn(batch, batchIndex)` for each chunk.
|
|
620
|
+
*
|
|
621
|
+
* @param {number} size - Batch size.
|
|
622
|
+
* @param {Function} fn - Called with (batch: Model[], index: number).
|
|
623
|
+
* @param {object} [conditions={}] - Optional WHERE conditions.
|
|
624
|
+
* @returns {Promise<void>}
|
|
625
|
+
*
|
|
626
|
+
* @example
|
|
627
|
+
* await User.chunk(100, async (users, i) => {
|
|
628
|
+
* for (const u of users) await u.update({ migrated: true });
|
|
629
|
+
* }, { active: true });
|
|
630
|
+
*/
|
|
631
|
+
static async chunk(size, fn, conditions = {})
|
|
632
|
+
{
|
|
633
|
+
return this.query().where(conditions).chunk(size, fn);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Get all records, optionally filtered.
|
|
638
|
+
* Alias for find() — for LINQ-familiarity.
|
|
639
|
+
*
|
|
640
|
+
* @param {object} [conditions={}] - WHERE conditions.
|
|
641
|
+
* @returns {Promise<Model[]>}
|
|
642
|
+
*/
|
|
643
|
+
static async all(conditions = {})
|
|
644
|
+
{
|
|
645
|
+
return this.find(conditions);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Get a random record.
|
|
650
|
+
*
|
|
651
|
+
* @param {object} [conditions={}] - Optional WHERE conditions.
|
|
652
|
+
* @returns {Promise<Model|null>}
|
|
653
|
+
*
|
|
654
|
+
* @example
|
|
655
|
+
* const luckyUser = await User.random();
|
|
656
|
+
* const randomAdmin = await User.random({ role: 'admin' });
|
|
657
|
+
*/
|
|
658
|
+
static async random(conditions = {})
|
|
659
|
+
{
|
|
660
|
+
const total = await this.count(conditions);
|
|
661
|
+
if (total === 0) return null;
|
|
662
|
+
const idx = Math.floor(Math.random() * total);
|
|
663
|
+
return this.query().where(conditions).offset(idx).first();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Pluck values for a single column across all matching records.
|
|
668
|
+
*
|
|
669
|
+
* @param {string} field - Column name to extract.
|
|
670
|
+
* @param {object} [conditions={}] - Optional WHERE conditions.
|
|
671
|
+
* @returns {Promise<Array>}
|
|
672
|
+
*
|
|
673
|
+
* @example
|
|
674
|
+
* const emails = await User.pluck('email');
|
|
675
|
+
* const adminNames = await User.pluck('name', { role: 'admin' });
|
|
676
|
+
*/
|
|
677
|
+
static async pluck(field, conditions = {})
|
|
678
|
+
{
|
|
679
|
+
return this.query().where(conditions).pluck(field);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// -- Relationships ----------------------------------
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Define a hasMany relationship.
|
|
686
|
+
* @param {Function} RelatedModel - The related Model class.
|
|
687
|
+
* @param {string} foreignKey - Foreign key column on the related table.
|
|
688
|
+
* @param {string} [localKey] - Local key (default: primary key).
|
|
689
|
+
*/
|
|
690
|
+
static hasMany(RelatedModel, foreignKey, localKey)
|
|
691
|
+
{
|
|
692
|
+
const pk = localKey || this._primaryKey();
|
|
693
|
+
if (!this._relations) this._relations = {};
|
|
694
|
+
this._relations[RelatedModel.name] = { type: 'hasMany', model: RelatedModel, foreignKey, localKey: pk };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Define a hasOne relationship.
|
|
699
|
+
* @param {Function} RelatedModel
|
|
700
|
+
* @param {string} foreignKey
|
|
701
|
+
* @param {string} [localKey]
|
|
702
|
+
*/
|
|
703
|
+
static hasOne(RelatedModel, foreignKey, localKey)
|
|
704
|
+
{
|
|
705
|
+
const pk = localKey || this._primaryKey();
|
|
706
|
+
if (!this._relations) this._relations = {};
|
|
707
|
+
this._relations[RelatedModel.name] = { type: 'hasOne', model: RelatedModel, foreignKey, localKey: pk };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Define a belongsTo relationship.
|
|
712
|
+
* @param {Function} RelatedModel
|
|
713
|
+
* @param {string} foreignKey - Foreign key column on THIS table.
|
|
714
|
+
* @param {string} [otherKey] - Key on the related table (default: its primary key).
|
|
715
|
+
*/
|
|
716
|
+
static belongsTo(RelatedModel, foreignKey, otherKey)
|
|
717
|
+
{
|
|
718
|
+
const ok = otherKey || RelatedModel._primaryKey();
|
|
719
|
+
if (!this._relations) this._relations = {};
|
|
720
|
+
this._relations[RelatedModel.name] = { type: 'belongsTo', model: RelatedModel, foreignKey, localKey: ok };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Define a many-to-many relationship through a junction/pivot table.
|
|
725
|
+
*
|
|
726
|
+
* @param {Function} RelatedModel - The related Model class.
|
|
727
|
+
* @param {object} opts - Relationship options.
|
|
728
|
+
* @param {string} opts.through - Junction table name (e.g. 'user_roles').
|
|
729
|
+
* @param {string} opts.foreignKey - Column on the junction table referencing THIS model.
|
|
730
|
+
* @param {string} opts.otherKey - Column on the junction table referencing the related model.
|
|
731
|
+
* @param {string} [opts.localKey] - Local key (default: primary key).
|
|
732
|
+
* @param {string} [opts.relatedKey] - Related model key (default: its primary key).
|
|
733
|
+
*
|
|
734
|
+
* @example
|
|
735
|
+
* User.belongsToMany(Role, {
|
|
736
|
+
* through: 'user_roles',
|
|
737
|
+
* foreignKey: 'userId',
|
|
738
|
+
* otherKey: 'roleId'
|
|
739
|
+
* });
|
|
740
|
+
* const roles = await user.load('Role'); // returns Role[]
|
|
741
|
+
*/
|
|
742
|
+
static belongsToMany(RelatedModel, opts = {})
|
|
743
|
+
{
|
|
744
|
+
if (!opts.through || !opts.foreignKey || !opts.otherKey)
|
|
745
|
+
{
|
|
746
|
+
throw new Error('belongsToMany requires through, foreignKey, and otherKey');
|
|
747
|
+
}
|
|
748
|
+
const pk = opts.localKey || this._primaryKey();
|
|
749
|
+
const rpk = opts.relatedKey || RelatedModel._primaryKey();
|
|
750
|
+
if (!this._relations) this._relations = {};
|
|
751
|
+
this._relations[RelatedModel.name] = {
|
|
752
|
+
type: 'belongsToMany',
|
|
753
|
+
model: RelatedModel,
|
|
754
|
+
through: opts.through,
|
|
755
|
+
foreignKey: opts.foreignKey,
|
|
756
|
+
otherKey: opts.otherKey,
|
|
757
|
+
localKey: pk,
|
|
758
|
+
relatedKey: rpk,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Load a related model for this instance.
|
|
764
|
+
*
|
|
765
|
+
* @param {string} relationName - Name of the related Model class.
|
|
766
|
+
* @returns {Promise<Model|Model[]|null>}
|
|
767
|
+
*/
|
|
768
|
+
async load(relationName)
|
|
769
|
+
{
|
|
770
|
+
const ctor = this.constructor;
|
|
771
|
+
const rel = ctor._relations && ctor._relations[relationName];
|
|
772
|
+
if (!rel) throw new Error(`Unknown relation "${relationName}" on ${ctor.name}`);
|
|
773
|
+
|
|
774
|
+
switch (rel.type)
|
|
775
|
+
{
|
|
776
|
+
case 'hasMany':
|
|
777
|
+
return rel.model.find({ [rel.foreignKey]: this[rel.localKey] });
|
|
778
|
+
case 'hasOne':
|
|
779
|
+
return rel.model.findOne({ [rel.foreignKey]: this[rel.localKey] });
|
|
780
|
+
case 'belongsTo':
|
|
781
|
+
return rel.model.findOne({ [rel.localKey]: this[rel.foreignKey] });
|
|
782
|
+
case 'belongsToMany':
|
|
783
|
+
{
|
|
784
|
+
// Query the junction table to find related IDs
|
|
785
|
+
const junctionRows = await ctor._adapter.execute({
|
|
786
|
+
action: 'select',
|
|
787
|
+
table: rel.through,
|
|
788
|
+
fields: [rel.otherKey],
|
|
789
|
+
where: [{ field: rel.foreignKey, op: '=', value: this[rel.localKey], logic: 'AND' }],
|
|
790
|
+
orderBy: [], joins: [], groupBy: [], having: [],
|
|
791
|
+
limit: null, offset: null, distinct: false,
|
|
792
|
+
});
|
|
793
|
+
if (!junctionRows.length) return [];
|
|
794
|
+
const relatedIds = junctionRows.map(r => r[rel.otherKey]);
|
|
795
|
+
return rel.model.query().whereIn(rel.relatedKey, relatedIds).exec();
|
|
796
|
+
}
|
|
797
|
+
default:
|
|
798
|
+
throw new Error(`Unknown relation type "${rel.type}"`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// -- Internal Static Helpers ------------------------
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Get the full schema including auto-fields.
|
|
806
|
+
* @returns {object}
|
|
807
|
+
* @private
|
|
808
|
+
*/
|
|
809
|
+
static _fullSchema()
|
|
810
|
+
{
|
|
811
|
+
const s = { ...this.schema };
|
|
812
|
+
if (this.timestamps)
|
|
813
|
+
{
|
|
814
|
+
if (!s.createdAt) s.createdAt = { type: 'datetime', default: () => new Date() };
|
|
815
|
+
if (!s.updatedAt) s.updatedAt = { type: 'datetime', default: () => new Date() };
|
|
816
|
+
}
|
|
817
|
+
if (this.softDelete)
|
|
818
|
+
{
|
|
819
|
+
if (!s.deletedAt) s.deletedAt = { type: 'datetime', nullable: true };
|
|
820
|
+
}
|
|
821
|
+
return s;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Get the primary key column name.
|
|
826
|
+
* @returns {string}
|
|
827
|
+
* @private
|
|
828
|
+
*/
|
|
829
|
+
static _primaryKey()
|
|
830
|
+
{
|
|
831
|
+
for (const [name, def] of Object.entries(this.schema))
|
|
832
|
+
{
|
|
833
|
+
if (def.primaryKey) return name;
|
|
834
|
+
}
|
|
835
|
+
return 'id'; // convention
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Create a model instance from a raw database row.
|
|
840
|
+
* @param {object} row
|
|
841
|
+
* @returns {Model}
|
|
842
|
+
* @private
|
|
843
|
+
*/
|
|
844
|
+
static _fromRow(row)
|
|
845
|
+
{
|
|
846
|
+
const instance = new this(row);
|
|
847
|
+
instance._persisted = true;
|
|
848
|
+
instance._snapshot();
|
|
849
|
+
return instance;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Run a lifecycle hook if defined.
|
|
854
|
+
* @param {string} hookName
|
|
855
|
+
* @param {*} data
|
|
856
|
+
* @returns {Promise<*>}
|
|
857
|
+
* @private
|
|
858
|
+
*/
|
|
859
|
+
static async _runHook(hookName, data)
|
|
860
|
+
{
|
|
861
|
+
// Check for static hook on class
|
|
862
|
+
if (typeof this[hookName] === 'function')
|
|
863
|
+
{
|
|
864
|
+
return this[hookName](data);
|
|
865
|
+
}
|
|
866
|
+
// Check hooks object
|
|
867
|
+
if (this.hooks && typeof this.hooks[hookName] === 'function')
|
|
868
|
+
{
|
|
869
|
+
return this.hooks[hookName](data);
|
|
870
|
+
}
|
|
871
|
+
return data;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Sync the table schema with the database (create table if not exists).
|
|
876
|
+
* @returns {Promise<void>}
|
|
877
|
+
*/
|
|
878
|
+
static async sync()
|
|
879
|
+
{
|
|
880
|
+
if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
|
|
881
|
+
return this._adapter.createTable(this.table, this._fullSchema());
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Drop the table.
|
|
886
|
+
* @returns {Promise<void>}
|
|
887
|
+
*/
|
|
888
|
+
static async drop()
|
|
889
|
+
{
|
|
890
|
+
if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
|
|
891
|
+
return this._adapter.dropTable(this.table);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
module.exports = Model;
|