laravel-workers 0.4.0 → 0.4.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.
@@ -1,1069 +1,1069 @@
1
- import { test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { table, DB, QueryBuilder } from '../db.mjs';
4
- import { Model } from '../model.mjs';
5
- import { check, makeSync } from '../hash.mjs';
6
-
7
- // PHP 8.3 / Laravel 13.37 oracle values (fresh-app vendor, 2026-10-19).
8
- const HASH10 = '$2y$10$cyT1llBUHIVeZFxw/hKj0eIeqIJNsMI0SUhpbh6K5FWh7NxcNqnf.';
9
- const HASH12 = '$2y$12$d0pIpZcCKtpClnDI9BCFgeYNp2hPFUuoGalG4focVyYVTL01s2ZmW';
10
- const HASH14 = '$2y$14$d0pIpZcCKtpClnDI9BCFgeYNp2hPFUuoGalG4focVyYVTL01s2ZmW';
11
- const HASH12_A = '$2a$12$d0pIpZcCKtpClnDI9BCFgeYNp2hPFUuoGalG4focVyYVTL01s2ZmW';
12
- const SHORT_2Y = '$2y$10$abcdefghijklmnopqrstuv';
13
- const ARGON = '$argon2id$v=19$m=65536,t=4,p=1$c29tZXNhbHQ$abc';
14
-
15
- function mockDb(responses = {}) {
16
- return {
17
- prepare(sql) {
18
- const stmt = {
19
- params: [],
20
- bind(...p) {
21
- this.params = p;
22
- return this;
23
- },
24
- async all() {
25
- return (responses.all ? responses.all(sql, this.params) : { results: [] });
26
- },
27
- async first() {
28
- return (responses.first ? responses.first(sql, this.params) : null);
29
- },
30
- async run() {
31
- return (responses.run ? responses.run(sql, this.params) : { success: true, meta: { changes: 1, last_row_id: 2 } });
32
- },
33
- };
34
- return stmt;
35
- },
36
- };
37
- }
38
-
39
- test('QueryBuilder toSql quotes identifiers and binds values', () => {
40
- const db = mockDb();
41
- const b = table(db, 'users')
42
- .select('id', 'name')
43
- .where('active', true)
44
- .where('age', '>', 18)
45
- .whereIn('role', ['admin', 'user'])
46
- .whereNull('deleted_at')
47
- .orderBy('id', 'desc')
48
- .limit(10)
49
- .offset(5);
50
- const { sql, params } = b.toSql();
51
- assert.equal(sql, 'SELECT "id", "name" FROM "users" WHERE "active" = ? AND "age" > ? AND "role" IN (?, ?) AND "deleted_at" IS NULL ORDER BY "id" DESC LIMIT ? OFFSET ?');
52
- assert.deepEqual(params, [true, 18, 'admin', 'user', 10, 5]);
53
- });
54
-
55
- test('QueryBuilder get maps mock rows', async () => {
56
- const db = mockDb({
57
- all: (sql) => ({ results: [{ id: 1, name: 'Ada' }] }),
58
- });
59
- const rows = await table(db, 'users').where('id', 1).get();
60
- assert.deepEqual(rows, [{ id: 1, name: 'Ada' }]);
61
- });
62
-
63
- test('QueryBuilder first returns null when empty', async () => {
64
- const db = mockDb({ all: () => ({ results: [] }) });
65
- const row = await table(db, 'users').first();
66
- assert.equal(row, null);
67
- });
68
-
69
- test('QueryBuilder count reads aggregate', async () => {
70
- const db = mockDb({ first: () => ({ aggregate: 7 }) });
71
- const n = await table(db, 'users').where('active', true).count();
72
- assert.equal(n, 7);
73
- });
74
-
75
- test('QueryBuilder insert/update/delete return metadata', async () => {
76
- const calls = [];
77
- const db = mockDb({
78
- run: (sql, params) => {
79
- calls.push({ sql, params });
80
- return { success: true, meta: { changes: 1, last_row_id: 42 } };
81
- },
82
- });
83
- const inserted = await table(db, 'users').insert({ name: 'Ada', active: true });
84
- assert.equal(inserted.lastInsertRowid, 42);
85
-
86
- const updated = await table(db, 'users').where('id', 42).update({ name: 'Grace' });
87
- assert.equal(updated.changes, 1);
88
-
89
- const deleted = await table(db, 'users').where('id', 42).delete();
90
- assert.equal(deleted.changes, 1);
91
-
92
- assert.match(calls[0].sql, /^INSERT INTO "users"/);
93
- assert.deepEqual(calls[0].params, ['Ada', 1]); // boolean bound as 1 (D1/SQLite cannot bind JS booleans)
94
- assert.match(calls[1].sql, /^UPDATE "users" SET "name" = \? WHERE "id" = \?$/);
95
- assert.deepEqual(calls[1].params, ['Grace', 42]);
96
- assert.match(calls[2].sql, /^DELETE FROM "users" WHERE "id" = \?$/);
97
- assert.deepEqual(calls[2].params, [42]);
98
- });
99
-
100
- test('QueryBuilder paginate', async () => {
101
- let paginateAllCalls = 0;
102
- const db = mockDb({
103
- first: (sql) => (/COUNT\(/.test(sql) ? { aggregate: 5 } : null),
104
- all: () => {
105
- paginateAllCalls++;
106
- return paginateAllCalls === 1 ? { results: [{ id: 3 }, { id: 4 }] } : { results: [] };
107
- },
108
- });
109
- const page = await table(db, 'users').orderBy('id').paginate(2, 2);
110
- assert.equal(page.total, 5);
111
- assert.equal(page.current_page, 2);
112
- assert.equal(page.last_page, 3);
113
- assert.deepEqual(page.data, [{ id: 3 }, { id: 4 }]);
114
- });
115
-
116
- test('Model base CRUD', async () => {
117
- class User extends Model {
118
- static table = 'users';
119
- static fillable = ['name', 'email'];
120
- static casts = { id: 'int' };
121
- }
122
-
123
- const state = { users: [{ id: 1, name: 'Ada', email: 'ada@example.com' }] };
124
- const db = mockDb({
125
- all: (sql) => ({ results: [...state.users] }),
126
- first: (sql, params) => {
127
- if (/COUNT\(/.test(sql)) return { aggregate: state.users.length };
128
- const id = params[0];
129
- return state.users.find((u) => u.id === id) || null;
130
- },
131
- run: (sql, params) => {
132
- if (/^INSERT/.test(sql)) {
133
- const row = { id: 2, name: params[0], email: params[1] };
134
- state.users.push(row);
135
- return { success: true, meta: { changes: 1, last_row_id: 2 } };
136
- }
137
- if (/^UPDATE/.test(sql)) {
138
- const user = state.users.find((u) => u.id === params[params.length - 1]);
139
- if (user) user.name = params[0];
140
- return { success: true, meta: { changes: 1 } };
141
- }
142
- if (/^DELETE/.test(sql)) {
143
- state.users = state.users.filter((u) => u.id !== params[0]);
144
- return { success: true, meta: { changes: 1 } };
145
- }
146
- return { success: true, meta: { changes: 0 } };
147
- },
148
- });
149
-
150
- const created = await User.create(db, { name: 'Grace', email: 'grace@example.com' });
151
- assert.equal(created.exists, true);
152
- assert.equal(created.get('id'), 2);
153
-
154
- const found = await User.find(db, 1);
155
- assert.ok(found instanceof User);
156
- assert.equal(found.get('name'), 'Ada');
157
-
158
- found.set('name', 'Ada Lovelace');
159
- await found.save(db);
160
- assert.equal(state.users.find((u) => u.id === 1).name, 'Ada Lovelace');
161
-
162
- const all = await User.all(db);
163
- assert.equal(all.length, 2);
164
-
165
- await found.delete(db);
166
- assert.equal(state.users.some((u) => u.id === 1), false);
167
- });
168
-
169
- // A users table with a frozen clock: the model timestamps are stubbed, so the
170
- // write carries a known timestamp (the oracle value: 1700000000 ->
171
- // '2023-11-14 22:13:20').
172
- function usersFixture() {
173
- const state = {
174
- users: [
175
- {
176
- id: 1,
177
- name: 'Ada',
178
- email: 'ada@example.com',
179
- created_at: '2024-01-01 00:00:00',
180
- updated_at: '2024-01-01 00:00:00',
181
- },
182
- ],
183
- };
184
- const calls = [];
185
- const db = mockDb({
186
- all: () => ({ results: state.users.map((u) => ({ ...u })) }),
187
- run: (sql, params) => {
188
- calls.push({ sql, params });
189
- const row = state.users.find((u) => u.id === params[params.length - 1]);
190
- if (row && /^UPDATE/.test(sql)) {
191
- const set = sql.slice(sql.indexOf('SET') + 4, sql.indexOf('WHERE'));
192
- [...set.matchAll(/"([^"]+)"/g)].forEach((m, i) => {
193
- row[m[1]] = params[i];
194
- });
195
- }
196
- return { success: true, meta: { changes: 1 } };
197
- },
198
- });
199
- return { state, calls, db };
200
- }
201
-
202
- class SaveUser extends Model {
203
- static table = 'users';
204
- static fillable = ['name', 'email'];
205
- }
206
- SaveUser.now = () => '2023-11-14 22:13:20';
207
-
208
- test('Model save() on an unchanged model writes nothing and returns true', async () => {
209
- // Eloquent's `save()` only writes when the model is dirty (`Model.php:1425`).
210
- const { state, calls, db } = usersFixture();
211
- const found = await SaveUser.find(db, 1);
212
- assert.equal(await found.save(db), true);
213
- assert.equal(calls.length, 0, 'an unchanged model issues no UPDATE');
214
- assert.equal(state.users[0].updated_at, '2024-01-01 00:00:00', 'updated_at is not bumped');
215
- });
216
-
217
- test('Model save() with an unchanged attribute is not dirty', async () => {
218
- // "Dirty" means "differs from the original" - setting the value the
219
- // attribute already has is not a change (`HasAttributes.php:2341`).
220
- const { state, calls, db } = usersFixture();
221
- const found = await SaveUser.find(db, 1);
222
- found.set('name', 'Ada');
223
- assert.equal(found.isDirty(), false);
224
- assert.equal(await found.save(db), true);
225
- assert.equal(calls.length, 0, 'an unchanged model issues no UPDATE');
226
- assert.equal(state.users[0].updated_at, '2024-01-01 00:00:00', 'updated_at is not bumped');
227
- });
228
-
229
- test('Model save() on a changed model updates the row and bumps updated_at', async () => {
230
- const { state, calls, db } = usersFixture();
231
- const found = await SaveUser.find(db, 1);
232
- found.set('name', 'Grace');
233
- assert.equal(found.isDirty(), true);
234
- assert.equal(await found.save(db), true);
235
- assert.equal(calls.length, 1);
236
- assert.equal(calls[0].sql, 'UPDATE "users" SET "name" = ?, "updated_at" = ? WHERE "id" = ?');
237
- assert.deepEqual(calls[0].params, ['Grace', '2023-11-14 22:13:20', 1]);
238
- assert.equal(state.users[0].name, 'Grace');
239
- assert.equal(state.users[0].updated_at, '2023-11-14 22:13:20');
240
- });
241
-
242
- test('Model save() on a new model inserts both timestamps and returns true', async () => {
243
- const calls = [];
244
- const db = mockDb({
245
- run: (sql, params) => {
246
- calls.push({ sql, params });
247
- return { success: true, meta: { changes: 1, last_row_id: 7 } };
248
- },
249
- });
250
- const user = new SaveUser();
251
- user.fill({ name: 'Grace', email: 'grace@example.com' });
252
- assert.equal(await user.save(db), true);
253
- assert.equal(calls.length, 1);
254
- assert.equal(calls[0].sql, 'INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES (?, ?, ?, ?)');
255
- assert.deepEqual(calls[0].params, ['Grace', 'grace@example.com', '2023-11-14 22:13:20', '2023-11-14 22:13:20']);
256
- assert.equal(user.get('id'), 7);
257
- });
258
-
259
- test('DB.table alias works', () => {
260
- assert.equal(typeof DB.table, 'function');
261
- assert.ok(new QueryBuilder(mockDb(), 'x') instanceof QueryBuilder);
262
- });
263
-
264
- test('orWhere / orWhereIn / orWhereNull render flat OR', () => {
265
- const b = table(mockDb(), 't')
266
- .where('a', 1)
267
- .orWhere('b', 2)
268
- .orWhereIn('c', [3, 4])
269
- .orWhereNull('d');
270
- const { sql, params } = b.toSql();
271
- assert.equal(sql, 'SELECT * FROM "t" WHERE "a" = ? OR "b" = ? OR "c" IN (?, ?) OR "d" IS NULL');
272
- assert.deepEqual(params, [1, 2, 3, 4]);
273
- });
274
-
275
- test('whereGroup nests with parentheses', () => {
276
- const { sql, params } = table(mockDb(), 't')
277
- .where('a', 1)
278
- .orWhereGroup((q) => q.where('x', 10).where('y', 20))
279
- .toSql();
280
- assert.equal(sql, 'SELECT * FROM "t" WHERE "a" = ? OR ("x" = ? AND "y" = ?)');
281
- assert.deepEqual(params, [1, 10, 20]);
282
- });
283
-
284
- test('whereBetween / whereNotBetween / whereNotIn / whereLike / whereColumn', () => {
285
- const a = table(mockDb(), 't').whereBetween('age', [18, 30]).toSql();
286
- assert.equal(a.sql, 'SELECT * FROM "t" WHERE "age" BETWEEN ? AND ?');
287
- assert.deepEqual(a.params, [18, 30]);
288
-
289
- const b = table(mockDb(), 't').whereNotBetween('age', [18, 30]).toSql();
290
- assert.equal(b.sql, 'SELECT * FROM "t" WHERE "age" NOT BETWEEN ? AND ?');
291
-
292
- const c = table(mockDb(), 't').whereNotIn('id', [1, 2]).toSql();
293
- assert.equal(c.sql, 'SELECT * FROM "t" WHERE "id" NOT IN (?, ?)');
294
- assert.deepEqual(c.params, [1, 2]);
295
-
296
- const d = table(mockDb(), 't').whereLike('name', 'A%').toSql();
297
- assert.equal(d.sql, 'SELECT * FROM "t" WHERE "name" LIKE ?');
298
- assert.deepEqual(d.params, ['A%']);
299
-
300
- const e = table(mockDb(), 't').whereColumn('a', '>', 'b').toSql();
301
- assert.equal(e.sql, 'SELECT * FROM "t" WHERE "a" > "b"');
302
- assert.deepEqual(e.params, []);
303
- });
304
-
305
- test('whereRaw binds and keeps order', () => {
306
- const { sql, params } = table(mockDb(), 't').where('a', 1).whereRaw('LOWER("x") = ?', ['y']).toSql();
307
- assert.equal(sql, 'SELECT * FROM "t" WHERE "a" = ? AND LOWER("x") = ?');
308
- assert.deepEqual(params, [1, 'y']);
309
- });
310
-
311
- test('join / leftJoin render ON clause', () => {
312
- const { sql } = table(mockDb(), 'posts')
313
- .select('posts.id', 'users.name')
314
- .join('users', 'users.id', '=', 'posts.user_id')
315
- .leftJoin('tags', 'tags.post_id', '=', 'posts.id')
316
- .toSql();
317
- assert.equal(sql, 'SELECT "posts"."id", "users"."name" FROM "posts" INNER JOIN "users" ON "users"."id" = "posts"."user_id" LEFT JOIN "tags" ON "tags"."post_id" = "posts"."id"');
318
- });
319
-
320
- test('selectRaw / groupBy / having / distinct', () => {
321
- const { sql, params } = table(mockDb(), 'orders')
322
- .selectRaw('customer_id, COUNT(*) AS n', [])
323
- .groupBy('customer_id')
324
- .having('n', '>', 5)
325
- .distinct()
326
- .toSql();
327
- assert.equal(sql, 'SELECT DISTINCT customer_id, COUNT(*) AS n FROM "orders" GROUP BY "customer_id" HAVING "n" > ?');
328
- assert.deepEqual(params, [5]);
329
- });
330
-
331
- test('selectRaw params precede where params', () => {
332
- const { sql, params } = table(mockDb(), 't').selectRaw('COALESCE(?, "x") AS v', ['d']).where('a', 1).toSql();
333
- assert.equal(sql, 'SELECT COALESCE(?, "x") AS v FROM "t" WHERE "a" = ?');
334
- assert.deepEqual(params, ['d', 1]);
335
- });
336
-
337
- test('orderByDesc / latest / oldest', () => {
338
- assert.equal(table(mockDb(), 't').orderByDesc('id').toSql().sql, 'SELECT * FROM "t" ORDER BY "id" DESC');
339
- assert.equal(table(mockDb(), 't').latest().toSql().sql, 'SELECT * FROM "t" ORDER BY "created_at" DESC');
340
- assert.equal(table(mockDb(), 't').oldest('id').toSql().sql, 'SELECT * FROM "t" ORDER BY "id" ASC');
341
- });
342
-
343
- test('exists / value / pluck', async () => {
344
- const db = mockDb({
345
- first: (sql) => (/SELECT 1 AS "e"/.test(sql) ? { e: 1 } : { id: 7 }),
346
- all: () => ({ results: [{ id: 1 }, { id: 2 }] }),
347
- });
348
- assert.equal(await table(db, 't').where('a', 1).exists(), true);
349
- assert.equal(await table(db, 't').value('id'), 7);
350
- assert.deepEqual(await table(db, 't').pluck('id'), [1, 2]);
351
- });
352
-
353
- class RelModel extends Model {
354
- static table = 'users';
355
- static fillable = ['name', 'role', 'score'];
356
- }
357
- class Owner extends Model {
358
- static table = 'owners';
359
- static fillable = ['name'];
360
- }
361
- class Pet extends Model {
362
- static table = 'pets';
363
- static fillable = ['owner_id', 'name'];
364
- }
365
-
366
- test('Model relations: hasMany / hasOne / belongsTo (default FK)', async () => {
367
- const db = mockDb({
368
- all: (sql, params) => {
369
- if (/FROM "pets"/.test(sql)) {
370
- return { results: params[0] === 1 ? [{ id: 10, owner_id: 1, name: 'Rex' }, { id: 11, owner_id: 1, name: 'Mia' }] : [] };
371
- }
372
- if (/FROM "owners"/.test(sql)) return { results: [{ id: 1, name: 'Ada' }] };
373
- return { results: [] };
374
- },
375
- });
376
- const owner = new Owner({ id: 1, name: 'Ada' });
377
- const pets = await owner.hasMany(db, Pet, 'owner_id');
378
- assert.equal(pets.length, 2);
379
- assert.ok(pets[0] instanceof Pet);
380
- assert.equal(pets[0].get('name'), 'Rex');
381
-
382
- const one = await owner.hasOne(db, Pet, 'owner_id');
383
- assert.ok(one instanceof Pet);
384
-
385
- const pet = new Pet({ id: 10, owner_id: 1 });
386
- const back = await pet.belongsTo(db, Owner);
387
- assert.ok(back instanceof Owner);
388
- assert.equal(back.get('name'), 'Ada');
389
- });
390
-
391
- test('Model firstOrCreate / updateOrCreate', async () => {
392
- const state = { rows: [{ id: 1, name: 'Ada', role: 'admin' }] };
393
- const db = mockDb({
394
- all: (sql, params) => ({ results: state.rows.filter((r) => r.name === params[0]) }),
395
- run: (sql, params) => {
396
- if (/^INSERT/.test(sql)) {
397
- const row = { id: state.rows.length + 1, name: params[0], role: params[1] };
398
- state.rows.push(row);
399
- return { success: true, meta: { changes: 1, last_row_id: row.id } };
400
- }
401
- if (/^UPDATE/.test(sql)) {
402
- const r = state.rows.find((x) => x.id === params[params.length - 1]);
403
- if (r) r.role = params[0];
404
- return { success: true, meta: { changes: 1 } };
405
- }
406
- return { success: true, meta: { changes: 0 } };
407
- },
408
- });
409
-
410
- const existing = await RelModel.firstOrCreate(db, { name: 'Ada' }, { role: 'admin' });
411
- assert.equal(existing.get('id'), 1);
412
- assert.equal(existing.get('role'), 'admin');
413
-
414
- const created = await RelModel.firstOrCreate(db, { name: 'Zed' }, { role: 'user' });
415
- assert.equal(created.exists, true);
416
- assert.equal(created.get('name'), 'Zed');
417
-
418
- const updated = await RelModel.updateOrCreate(db, { name: 'Ada' }, { role: 'root' });
419
- assert.equal(updated.get('role'), 'root');
420
- });
421
-
422
- test('Model refresh / increment', async () => {
423
- const db = mockDb({
424
- all: () => ({ results: [{ id: 1, name: 'Fresh', score: 5 }] }),
425
- run: () => ({ success: true, meta: { changes: 1 } }),
426
- });
427
- const m = new RelModel({ id: 1, name: 'Stale', score: 4 });
428
- await m.refresh(db);
429
- assert.equal(m.get('name'), 'Fresh');
430
- // Laravel's increment() answers the affected-row count (not the new value)
431
- // and writes ONE atomic statement - it does not touch updated_at.
432
- const changed = await m.increment(db, 'score', 2);
433
- assert.equal(changed, 1);
434
- assert.equal(m.get('score'), 7);
435
- });
436
-
437
- test('where(fn) nests a group (Laravel closure grouping)', () => {
438
- const { sql, params } = table(mockDb(), 'posts')
439
- .where('active', true)
440
- .where((q) => q.where('a', 1).orWhere('b', 2))
441
- .orWhere((q) => q.where('c', 3))
442
- .toSql();
443
- assert.equal(sql, 'SELECT * FROM "posts" WHERE "active" = ? AND ("a" = ? OR "b" = ?) OR ("c" = ?)');
444
- assert.deepEqual(params, [true, 1, 2, 3]);
445
- });
446
-
447
- test('whereDate / whereYear / whereMonth / whereDay / whereTime', () => {
448
- assert.equal(
449
- table(mockDb(), 't').whereDate('created_at', '2026-01-02').toSql().sql,
450
- 'SELECT * FROM "t" WHERE date("created_at") = CAST(? AS TEXT)'
451
- );
452
- assert.equal(
453
- table(mockDb(), 't').whereYear('created_at', '>', 2025).toSql().sql,
454
- 'SELECT * FROM "t" WHERE strftime(\'%Y\', "created_at") > CAST(? AS TEXT)'
455
- );
456
- assert.equal(
457
- table(mockDb(), 't').whereMonth('created_at', 3).toSql().sql,
458
- 'SELECT * FROM "t" WHERE strftime(\'%m\', "created_at") = CAST(? AS TEXT)'
459
- );
460
- assert.equal(
461
- table(mockDb(), 't').whereDay('created_at', 9).toSql().sql,
462
- 'SELECT * FROM "t" WHERE strftime(\'%d\', "created_at") = CAST(? AS TEXT)'
463
- );
464
- assert.equal(
465
- table(mockDb(), 't').whereTime('created_at', '12:00:00').toSql().sql,
466
- 'SELECT * FROM "t" WHERE time("created_at") = CAST(? AS TEXT)'
467
- );
468
- });
469
-
470
- test('aggregates sum/avg/min/max read the aggregate column', async () => {
471
- const db = mockDb({ first: () => ({ aggregate: 12.5 }) });
472
- assert.equal(await table(db, 't').sum('amount'), 12.5);
473
- assert.equal(await table(db, 't').avg('amount'), 12.5);
474
- assert.equal(await table(db, 't').min('amount'), 12.5);
475
- assert.equal(await table(db, 't').max('amount'), 12.5);
476
- const nullDb = mockDb({ first: () => ({ aggregate: null }) });
477
- // Laravel's sum(): `return $result ?: 0;` - an empty set answers 0.
478
- assert.equal(await table(nullDb, 't').sum('amount'), 0);
479
- // avg/min/max keep the null (Laravel only coerces sum).
480
- assert.equal(await table(nullDb, 't').avg('amount'), null);
481
- assert.equal(await table(nullDb, 't').min('amount'), null);
482
- assert.equal(await table(nullDb, 't').max('amount'), null);
483
- });
484
-
485
- test('upsert builds ON CONFLICT DO UPDATE / DO NOTHING', async () => {
486
- const calls = [];
487
- const db = mockDb({ run: (sql, params) => { calls.push({ sql, params }); return { success: true, meta: { changes: 2 } }; } });
488
- const r = await table(db, 'users').upsert([{ id: 1, name: 'A' }, { id: 2, name: 'B' }], ['id'], ['name']);
489
- assert.equal(r.changes, 2);
490
- assert.equal(calls[0].sql, 'INSERT INTO "users" ("id", "name") VALUES (?, ?), (?, ?) ON CONFLICT ("id") DO UPDATE SET "name" = excluded."name"');
491
- assert.deepEqual(calls[0].params, [1, 'A', 2, 'B']);
492
- await table(db, 'users').upsert({ id: 1, name: 'A' }, 'id', []);
493
- assert.equal(calls[1].sql, 'INSERT INTO "users" ("id", "name") VALUES (?, ?) ON CONFLICT ("id") DO NOTHING');
494
- });
495
-
496
- test('insertGetId / truncate / increment / decrement', async () => {
497
- const calls = [];
498
- const db = mockDb({ run: (sql, params) => { calls.push({ sql, params }); return { success: true, meta: { changes: 1, last_row_id: 9 } }; } });
499
- assert.equal(await table(db, 't').insertGetId({ name: 'A' }), 9);
500
- assert.match(calls[0].sql, /^INSERT INTO "t"/);
501
- const tr = await table(db, 't').truncate();
502
- assert.equal(tr.success, true);
503
- assert.equal(calls[1].sql, 'DELETE FROM "t"');
504
- await table(db, 't').where('id', 3).increment('views', 2);
505
- assert.equal(calls[2].sql, 'UPDATE "t" SET "views" = "views" + ? WHERE "id" = ?');
506
- assert.deepEqual(calls[2].params, [2, 3]);
507
- await table(db, 't').where('id', 3).decrement('views', 2);
508
- assert.deepEqual(calls[3].params, [-2, 3]);
509
- });
510
-
511
- test('updateOrInsert updates when found, inserts otherwise', async () => {
512
- const calls = [];
513
- let found = { id: 1, name: 'A' };
514
- const db = mockDb({
515
- all: () => (found ? { results: [{ ...found }] } : { results: [] }),
516
- run: (sql, params) => { calls.push({ sql, params }); return { success: true, meta: { changes: 1, last_row_id: 5 } }; },
517
- });
518
- const upd = await table(db, 't').updateOrInsert({ id: 1 }, { name: 'B' });
519
- assert.equal(upd.changes, 1);
520
- assert.match(calls[0].sql, /^UPDATE "t" SET "name" = \? WHERE "id" = \?$/);
521
- assert.deepEqual(calls[0].params, ['B', 1]);
522
-
523
- found = null;
524
- await table(db, 't').updateOrInsert({ id: 2 }, { name: 'C' });
525
- assert.match(calls[1].sql, /^INSERT INTO "t" \("id", "name"\) VALUES \(\?, \?\)$/);
526
- assert.deepEqual(calls[1].params, [2, 'C']);
527
- });
528
-
529
- test('chunk paginates until exhausted', async () => {
530
- const rows = [{ id: 1 }, { id: 2 }, { id: 3 }];
531
- const db = mockDb({
532
- all: (sql, params) => {
533
- const limit = params[params.length - 2];
534
- const offset = params[params.length - 1];
535
- return { results: rows.slice(offset, offset + limit) };
536
- },
537
- });
538
- const seen = [];
539
- await table(db, 't').chunk(2, (batch, page) => { seen.push([page, batch.length]); });
540
- assert.deepEqual(seen, [[1, 2], [2, 1]]);
541
- });
542
-
543
- class CastModel extends Model {
544
- static table = 'items';
545
- static fillable = ['name', 'meta', 'price', 'secret', 'first_name'];
546
- static casts = { meta: 'json', price: 'decimal:2', secret: 'int' };
547
- static hidden = ['secret'];
548
- static appends = ['label'];
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { table, DB, QueryBuilder } from '../db.mjs';
4
+ import { Model } from '../model.mjs';
5
+ import { check, makeSync } from '../hash.mjs';
6
+
7
+ // PHP 8.3 / Laravel 13.37 oracle values (fresh-app vendor, 2026-10-19).
8
+ const HASH10 = '$2y$10$cyT1llBUHIVeZFxw/hKj0eIeqIJNsMI0SUhpbh6K5FWh7NxcNqnf.';
9
+ const HASH12 = '$2y$12$d0pIpZcCKtpClnDI9BCFgeYNp2hPFUuoGalG4focVyYVTL01s2ZmW';
10
+ const HASH14 = '$2y$14$d0pIpZcCKtpClnDI9BCFgeYNp2hPFUuoGalG4focVyYVTL01s2ZmW';
11
+ const HASH12_A = '$2a$12$d0pIpZcCKtpClnDI9BCFgeYNp2hPFUuoGalG4focVyYVTL01s2ZmW';
12
+ const SHORT_2Y = '$2y$10$abcdefghijklmnopqrstuv';
13
+ const ARGON = '$argon2id$v=19$m=65536,t=4,p=1$c29tZXNhbHQ$abc';
14
+
15
+ function mockDb(responses = {}) {
16
+ return {
17
+ prepare(sql) {
18
+ const stmt = {
19
+ params: [],
20
+ bind(...p) {
21
+ this.params = p;
22
+ return this;
23
+ },
24
+ async all() {
25
+ return (responses.all ? responses.all(sql, this.params) : { results: [] });
26
+ },
27
+ async first() {
28
+ return (responses.first ? responses.first(sql, this.params) : null);
29
+ },
30
+ async run() {
31
+ return (responses.run ? responses.run(sql, this.params) : { success: true, meta: { changes: 1, last_row_id: 2 } });
32
+ },
33
+ };
34
+ return stmt;
35
+ },
36
+ };
37
+ }
38
+
39
+ test('QueryBuilder toSql quotes identifiers and binds values', () => {
40
+ const db = mockDb();
41
+ const b = table(db, 'users')
42
+ .select('id', 'name')
43
+ .where('active', true)
44
+ .where('age', '>', 18)
45
+ .whereIn('role', ['admin', 'user'])
46
+ .whereNull('deleted_at')
47
+ .orderBy('id', 'desc')
48
+ .limit(10)
49
+ .offset(5);
50
+ const { sql, params } = b.toSql();
51
+ assert.equal(sql, 'SELECT "id", "name" FROM "users" WHERE "active" = ? AND "age" > ? AND "role" IN (?, ?) AND "deleted_at" IS NULL ORDER BY "id" DESC LIMIT ? OFFSET ?');
52
+ assert.deepEqual(params, [true, 18, 'admin', 'user', 10, 5]);
53
+ });
54
+
55
+ test('QueryBuilder get maps mock rows', async () => {
56
+ const db = mockDb({
57
+ all: (sql) => ({ results: [{ id: 1, name: 'Ada' }] }),
58
+ });
59
+ const rows = await table(db, 'users').where('id', 1).get();
60
+ assert.deepEqual(rows, [{ id: 1, name: 'Ada' }]);
61
+ });
62
+
63
+ test('QueryBuilder first returns null when empty', async () => {
64
+ const db = mockDb({ all: () => ({ results: [] }) });
65
+ const row = await table(db, 'users').first();
66
+ assert.equal(row, null);
67
+ });
68
+
69
+ test('QueryBuilder count reads aggregate', async () => {
70
+ const db = mockDb({ first: () => ({ aggregate: 7 }) });
71
+ const n = await table(db, 'users').where('active', true).count();
72
+ assert.equal(n, 7);
73
+ });
74
+
75
+ test('QueryBuilder insert/update/delete return metadata', async () => {
76
+ const calls = [];
77
+ const db = mockDb({
78
+ run: (sql, params) => {
79
+ calls.push({ sql, params });
80
+ return { success: true, meta: { changes: 1, last_row_id: 42 } };
81
+ },
82
+ });
83
+ const inserted = await table(db, 'users').insert({ name: 'Ada', active: true });
84
+ assert.equal(inserted.lastInsertRowid, 42);
85
+
86
+ const updated = await table(db, 'users').where('id', 42).update({ name: 'Grace' });
87
+ assert.equal(updated.changes, 1);
88
+
89
+ const deleted = await table(db, 'users').where('id', 42).delete();
90
+ assert.equal(deleted.changes, 1);
91
+
92
+ assert.match(calls[0].sql, /^INSERT INTO "users"/);
93
+ assert.deepEqual(calls[0].params, ['Ada', 1]); // boolean bound as 1 (D1/SQLite cannot bind JS booleans)
94
+ assert.match(calls[1].sql, /^UPDATE "users" SET "name" = \? WHERE "id" = \?$/);
95
+ assert.deepEqual(calls[1].params, ['Grace', 42]);
96
+ assert.match(calls[2].sql, /^DELETE FROM "users" WHERE "id" = \?$/);
97
+ assert.deepEqual(calls[2].params, [42]);
98
+ });
99
+
100
+ test('QueryBuilder paginate', async () => {
101
+ let paginateAllCalls = 0;
102
+ const db = mockDb({
103
+ first: (sql) => (/COUNT\(/.test(sql) ? { aggregate: 5 } : null),
104
+ all: () => {
105
+ paginateAllCalls++;
106
+ return paginateAllCalls === 1 ? { results: [{ id: 3 }, { id: 4 }] } : { results: [] };
107
+ },
108
+ });
109
+ const page = await table(db, 'users').orderBy('id').paginate(2, 2);
110
+ assert.equal(page.total, 5);
111
+ assert.equal(page.current_page, 2);
112
+ assert.equal(page.last_page, 3);
113
+ assert.deepEqual(page.data, [{ id: 3 }, { id: 4 }]);
114
+ });
115
+
116
+ test('Model base CRUD', async () => {
117
+ class User extends Model {
118
+ static table = 'users';
119
+ static fillable = ['name', 'email'];
120
+ static casts = { id: 'int' };
121
+ }
122
+
123
+ const state = { users: [{ id: 1, name: 'Ada', email: 'ada@example.com' }] };
124
+ const db = mockDb({
125
+ all: (sql) => ({ results: [...state.users] }),
126
+ first: (sql, params) => {
127
+ if (/COUNT\(/.test(sql)) return { aggregate: state.users.length };
128
+ const id = params[0];
129
+ return state.users.find((u) => u.id === id) || null;
130
+ },
131
+ run: (sql, params) => {
132
+ if (/^INSERT/.test(sql)) {
133
+ const row = { id: 2, name: params[0], email: params[1] };
134
+ state.users.push(row);
135
+ return { success: true, meta: { changes: 1, last_row_id: 2 } };
136
+ }
137
+ if (/^UPDATE/.test(sql)) {
138
+ const user = state.users.find((u) => u.id === params[params.length - 1]);
139
+ if (user) user.name = params[0];
140
+ return { success: true, meta: { changes: 1 } };
141
+ }
142
+ if (/^DELETE/.test(sql)) {
143
+ state.users = state.users.filter((u) => u.id !== params[0]);
144
+ return { success: true, meta: { changes: 1 } };
145
+ }
146
+ return { success: true, meta: { changes: 0 } };
147
+ },
148
+ });
149
+
150
+ const created = await User.create(db, { name: 'Grace', email: 'grace@example.com' });
151
+ assert.equal(created.exists, true);
152
+ assert.equal(created.get('id'), 2);
153
+
154
+ const found = await User.find(db, 1);
155
+ assert.ok(found instanceof User);
156
+ assert.equal(found.get('name'), 'Ada');
157
+
158
+ found.set('name', 'Ada Lovelace');
159
+ await found.save(db);
160
+ assert.equal(state.users.find((u) => u.id === 1).name, 'Ada Lovelace');
161
+
162
+ const all = await User.all(db);
163
+ assert.equal(all.length, 2);
164
+
165
+ await found.delete(db);
166
+ assert.equal(state.users.some((u) => u.id === 1), false);
167
+ });
168
+
169
+ // A users table with a frozen clock: the model timestamps are stubbed, so the
170
+ // write carries a known timestamp (the oracle value: 1700000000 ->
171
+ // '2023-11-14 22:13:20').
172
+ function usersFixture() {
173
+ const state = {
174
+ users: [
175
+ {
176
+ id: 1,
177
+ name: 'Ada',
178
+ email: 'ada@example.com',
179
+ created_at: '2024-01-01 00:00:00',
180
+ updated_at: '2024-01-01 00:00:00',
181
+ },
182
+ ],
183
+ };
184
+ const calls = [];
185
+ const db = mockDb({
186
+ all: () => ({ results: state.users.map((u) => ({ ...u })) }),
187
+ run: (sql, params) => {
188
+ calls.push({ sql, params });
189
+ const row = state.users.find((u) => u.id === params[params.length - 1]);
190
+ if (row && /^UPDATE/.test(sql)) {
191
+ const set = sql.slice(sql.indexOf('SET') + 4, sql.indexOf('WHERE'));
192
+ [...set.matchAll(/"([^"]+)"/g)].forEach((m, i) => {
193
+ row[m[1]] = params[i];
194
+ });
195
+ }
196
+ return { success: true, meta: { changes: 1 } };
197
+ },
198
+ });
199
+ return { state, calls, db };
200
+ }
201
+
202
+ class SaveUser extends Model {
203
+ static table = 'users';
204
+ static fillable = ['name', 'email'];
205
+ }
206
+ SaveUser.now = () => '2023-11-14 22:13:20';
207
+
208
+ test('Model save() on an unchanged model writes nothing and returns true', async () => {
209
+ // Eloquent's `save()` only writes when the model is dirty (`Model.php:1425`).
210
+ const { state, calls, db } = usersFixture();
211
+ const found = await SaveUser.find(db, 1);
212
+ assert.equal(await found.save(db), true);
213
+ assert.equal(calls.length, 0, 'an unchanged model issues no UPDATE');
214
+ assert.equal(state.users[0].updated_at, '2024-01-01 00:00:00', 'updated_at is not bumped');
215
+ });
216
+
217
+ test('Model save() with an unchanged attribute is not dirty', async () => {
218
+ // "Dirty" means "differs from the original" - setting the value the
219
+ // attribute already has is not a change (`HasAttributes.php:2341`).
220
+ const { state, calls, db } = usersFixture();
221
+ const found = await SaveUser.find(db, 1);
222
+ found.set('name', 'Ada');
223
+ assert.equal(found.isDirty(), false);
224
+ assert.equal(await found.save(db), true);
225
+ assert.equal(calls.length, 0, 'an unchanged model issues no UPDATE');
226
+ assert.equal(state.users[0].updated_at, '2024-01-01 00:00:00', 'updated_at is not bumped');
227
+ });
228
+
229
+ test('Model save() on a changed model updates the row and bumps updated_at', async () => {
230
+ const { state, calls, db } = usersFixture();
231
+ const found = await SaveUser.find(db, 1);
232
+ found.set('name', 'Grace');
233
+ assert.equal(found.isDirty(), true);
234
+ assert.equal(await found.save(db), true);
235
+ assert.equal(calls.length, 1);
236
+ assert.equal(calls[0].sql, 'UPDATE "users" SET "name" = ?, "updated_at" = ? WHERE "id" = ?');
237
+ assert.deepEqual(calls[0].params, ['Grace', '2023-11-14 22:13:20', 1]);
238
+ assert.equal(state.users[0].name, 'Grace');
239
+ assert.equal(state.users[0].updated_at, '2023-11-14 22:13:20');
240
+ });
241
+
242
+ test('Model save() on a new model inserts both timestamps and returns true', async () => {
243
+ const calls = [];
244
+ const db = mockDb({
245
+ run: (sql, params) => {
246
+ calls.push({ sql, params });
247
+ return { success: true, meta: { changes: 1, last_row_id: 7 } };
248
+ },
249
+ });
250
+ const user = new SaveUser();
251
+ user.fill({ name: 'Grace', email: 'grace@example.com' });
252
+ assert.equal(await user.save(db), true);
253
+ assert.equal(calls.length, 1);
254
+ assert.equal(calls[0].sql, 'INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES (?, ?, ?, ?)');
255
+ assert.deepEqual(calls[0].params, ['Grace', 'grace@example.com', '2023-11-14 22:13:20', '2023-11-14 22:13:20']);
256
+ assert.equal(user.get('id'), 7);
257
+ });
258
+
259
+ test('DB.table alias works', () => {
260
+ assert.equal(typeof DB.table, 'function');
261
+ assert.ok(new QueryBuilder(mockDb(), 'x') instanceof QueryBuilder);
262
+ });
263
+
264
+ test('orWhere / orWhereIn / orWhereNull render flat OR', () => {
265
+ const b = table(mockDb(), 't')
266
+ .where('a', 1)
267
+ .orWhere('b', 2)
268
+ .orWhereIn('c', [3, 4])
269
+ .orWhereNull('d');
270
+ const { sql, params } = b.toSql();
271
+ assert.equal(sql, 'SELECT * FROM "t" WHERE "a" = ? OR "b" = ? OR "c" IN (?, ?) OR "d" IS NULL');
272
+ assert.deepEqual(params, [1, 2, 3, 4]);
273
+ });
274
+
275
+ test('whereGroup nests with parentheses', () => {
276
+ const { sql, params } = table(mockDb(), 't')
277
+ .where('a', 1)
278
+ .orWhereGroup((q) => q.where('x', 10).where('y', 20))
279
+ .toSql();
280
+ assert.equal(sql, 'SELECT * FROM "t" WHERE "a" = ? OR ("x" = ? AND "y" = ?)');
281
+ assert.deepEqual(params, [1, 10, 20]);
282
+ });
283
+
284
+ test('whereBetween / whereNotBetween / whereNotIn / whereLike / whereColumn', () => {
285
+ const a = table(mockDb(), 't').whereBetween('age', [18, 30]).toSql();
286
+ assert.equal(a.sql, 'SELECT * FROM "t" WHERE "age" BETWEEN ? AND ?');
287
+ assert.deepEqual(a.params, [18, 30]);
288
+
289
+ const b = table(mockDb(), 't').whereNotBetween('age', [18, 30]).toSql();
290
+ assert.equal(b.sql, 'SELECT * FROM "t" WHERE "age" NOT BETWEEN ? AND ?');
291
+
292
+ const c = table(mockDb(), 't').whereNotIn('id', [1, 2]).toSql();
293
+ assert.equal(c.sql, 'SELECT * FROM "t" WHERE "id" NOT IN (?, ?)');
294
+ assert.deepEqual(c.params, [1, 2]);
295
+
296
+ const d = table(mockDb(), 't').whereLike('name', 'A%').toSql();
297
+ assert.equal(d.sql, 'SELECT * FROM "t" WHERE "name" LIKE ?');
298
+ assert.deepEqual(d.params, ['A%']);
299
+
300
+ const e = table(mockDb(), 't').whereColumn('a', '>', 'b').toSql();
301
+ assert.equal(e.sql, 'SELECT * FROM "t" WHERE "a" > "b"');
302
+ assert.deepEqual(e.params, []);
303
+ });
304
+
305
+ test('whereRaw binds and keeps order', () => {
306
+ const { sql, params } = table(mockDb(), 't').where('a', 1).whereRaw('LOWER("x") = ?', ['y']).toSql();
307
+ assert.equal(sql, 'SELECT * FROM "t" WHERE "a" = ? AND LOWER("x") = ?');
308
+ assert.deepEqual(params, [1, 'y']);
309
+ });
310
+
311
+ test('join / leftJoin render ON clause', () => {
312
+ const { sql } = table(mockDb(), 'posts')
313
+ .select('posts.id', 'users.name')
314
+ .join('users', 'users.id', '=', 'posts.user_id')
315
+ .leftJoin('tags', 'tags.post_id', '=', 'posts.id')
316
+ .toSql();
317
+ assert.equal(sql, 'SELECT "posts"."id", "users"."name" FROM "posts" INNER JOIN "users" ON "users"."id" = "posts"."user_id" LEFT JOIN "tags" ON "tags"."post_id" = "posts"."id"');
318
+ });
319
+
320
+ test('selectRaw / groupBy / having / distinct', () => {
321
+ const { sql, params } = table(mockDb(), 'orders')
322
+ .selectRaw('customer_id, COUNT(*) AS n', [])
323
+ .groupBy('customer_id')
324
+ .having('n', '>', 5)
325
+ .distinct()
326
+ .toSql();
327
+ assert.equal(sql, 'SELECT DISTINCT customer_id, COUNT(*) AS n FROM "orders" GROUP BY "customer_id" HAVING "n" > ?');
328
+ assert.deepEqual(params, [5]);
329
+ });
330
+
331
+ test('selectRaw params precede where params', () => {
332
+ const { sql, params } = table(mockDb(), 't').selectRaw('COALESCE(?, "x") AS v', ['d']).where('a', 1).toSql();
333
+ assert.equal(sql, 'SELECT COALESCE(?, "x") AS v FROM "t" WHERE "a" = ?');
334
+ assert.deepEqual(params, ['d', 1]);
335
+ });
336
+
337
+ test('orderByDesc / latest / oldest', () => {
338
+ assert.equal(table(mockDb(), 't').orderByDesc('id').toSql().sql, 'SELECT * FROM "t" ORDER BY "id" DESC');
339
+ assert.equal(table(mockDb(), 't').latest().toSql().sql, 'SELECT * FROM "t" ORDER BY "created_at" DESC');
340
+ assert.equal(table(mockDb(), 't').oldest('id').toSql().sql, 'SELECT * FROM "t" ORDER BY "id" ASC');
341
+ });
342
+
343
+ test('exists / value / pluck', async () => {
344
+ const db = mockDb({
345
+ first: (sql) => (/SELECT 1 AS "e"/.test(sql) ? { e: 1 } : { id: 7 }),
346
+ all: () => ({ results: [{ id: 1 }, { id: 2 }] }),
347
+ });
348
+ assert.equal(await table(db, 't').where('a', 1).exists(), true);
349
+ assert.equal(await table(db, 't').value('id'), 7);
350
+ assert.deepEqual(await table(db, 't').pluck('id'), [1, 2]);
351
+ });
352
+
353
+ class RelModel extends Model {
354
+ static table = 'users';
355
+ static fillable = ['name', 'role', 'score'];
356
+ }
357
+ class Owner extends Model {
358
+ static table = 'owners';
359
+ static fillable = ['name'];
360
+ }
361
+ class Pet extends Model {
362
+ static table = 'pets';
363
+ static fillable = ['owner_id', 'name'];
364
+ }
365
+
366
+ test('Model relations: hasMany / hasOne / belongsTo (default FK)', async () => {
367
+ const db = mockDb({
368
+ all: (sql, params) => {
369
+ if (/FROM "pets"/.test(sql)) {
370
+ return { results: params[0] === 1 ? [{ id: 10, owner_id: 1, name: 'Rex' }, { id: 11, owner_id: 1, name: 'Mia' }] : [] };
371
+ }
372
+ if (/FROM "owners"/.test(sql)) return { results: [{ id: 1, name: 'Ada' }] };
373
+ return { results: [] };
374
+ },
375
+ });
376
+ const owner = new Owner({ id: 1, name: 'Ada' });
377
+ const pets = await owner.hasMany(db, Pet, 'owner_id');
378
+ assert.equal(pets.length, 2);
379
+ assert.ok(pets[0] instanceof Pet);
380
+ assert.equal(pets[0].get('name'), 'Rex');
381
+
382
+ const one = await owner.hasOne(db, Pet, 'owner_id');
383
+ assert.ok(one instanceof Pet);
384
+
385
+ const pet = new Pet({ id: 10, owner_id: 1 });
386
+ const back = await pet.belongsTo(db, Owner);
387
+ assert.ok(back instanceof Owner);
388
+ assert.equal(back.get('name'), 'Ada');
389
+ });
390
+
391
+ test('Model firstOrCreate / updateOrCreate', async () => {
392
+ const state = { rows: [{ id: 1, name: 'Ada', role: 'admin' }] };
393
+ const db = mockDb({
394
+ all: (sql, params) => ({ results: state.rows.filter((r) => r.name === params[0]) }),
395
+ run: (sql, params) => {
396
+ if (/^INSERT/.test(sql)) {
397
+ const row = { id: state.rows.length + 1, name: params[0], role: params[1] };
398
+ state.rows.push(row);
399
+ return { success: true, meta: { changes: 1, last_row_id: row.id } };
400
+ }
401
+ if (/^UPDATE/.test(sql)) {
402
+ const r = state.rows.find((x) => x.id === params[params.length - 1]);
403
+ if (r) r.role = params[0];
404
+ return { success: true, meta: { changes: 1 } };
405
+ }
406
+ return { success: true, meta: { changes: 0 } };
407
+ },
408
+ });
409
+
410
+ const existing = await RelModel.firstOrCreate(db, { name: 'Ada' }, { role: 'admin' });
411
+ assert.equal(existing.get('id'), 1);
412
+ assert.equal(existing.get('role'), 'admin');
413
+
414
+ const created = await RelModel.firstOrCreate(db, { name: 'Zed' }, { role: 'user' });
415
+ assert.equal(created.exists, true);
416
+ assert.equal(created.get('name'), 'Zed');
417
+
418
+ const updated = await RelModel.updateOrCreate(db, { name: 'Ada' }, { role: 'root' });
419
+ assert.equal(updated.get('role'), 'root');
420
+ });
421
+
422
+ test('Model refresh / increment', async () => {
423
+ const db = mockDb({
424
+ all: () => ({ results: [{ id: 1, name: 'Fresh', score: 5 }] }),
425
+ run: () => ({ success: true, meta: { changes: 1 } }),
426
+ });
427
+ const m = new RelModel({ id: 1, name: 'Stale', score: 4 });
428
+ await m.refresh(db);
429
+ assert.equal(m.get('name'), 'Fresh');
430
+ // Laravel's increment() answers the affected-row count (not the new value)
431
+ // and writes ONE atomic statement - it does not touch updated_at.
432
+ const changed = await m.increment(db, 'score', 2);
433
+ assert.equal(changed, 1);
434
+ assert.equal(m.get('score'), 7);
435
+ });
436
+
437
+ test('where(fn) nests a group (Laravel closure grouping)', () => {
438
+ const { sql, params } = table(mockDb(), 'posts')
439
+ .where('active', true)
440
+ .where((q) => q.where('a', 1).orWhere('b', 2))
441
+ .orWhere((q) => q.where('c', 3))
442
+ .toSql();
443
+ assert.equal(sql, 'SELECT * FROM "posts" WHERE "active" = ? AND ("a" = ? OR "b" = ?) OR ("c" = ?)');
444
+ assert.deepEqual(params, [true, 1, 2, 3]);
445
+ });
446
+
447
+ test('whereDate / whereYear / whereMonth / whereDay / whereTime', () => {
448
+ assert.equal(
449
+ table(mockDb(), 't').whereDate('created_at', '2026-01-02').toSql().sql,
450
+ 'SELECT * FROM "t" WHERE date("created_at") = CAST(? AS TEXT)'
451
+ );
452
+ assert.equal(
453
+ table(mockDb(), 't').whereYear('created_at', '>', 2025).toSql().sql,
454
+ 'SELECT * FROM "t" WHERE strftime(\'%Y\', "created_at") > CAST(? AS TEXT)'
455
+ );
456
+ assert.equal(
457
+ table(mockDb(), 't').whereMonth('created_at', 3).toSql().sql,
458
+ 'SELECT * FROM "t" WHERE strftime(\'%m\', "created_at") = CAST(? AS TEXT)'
459
+ );
460
+ assert.equal(
461
+ table(mockDb(), 't').whereDay('created_at', 9).toSql().sql,
462
+ 'SELECT * FROM "t" WHERE strftime(\'%d\', "created_at") = CAST(? AS TEXT)'
463
+ );
464
+ assert.equal(
465
+ table(mockDb(), 't').whereTime('created_at', '12:00:00').toSql().sql,
466
+ 'SELECT * FROM "t" WHERE time("created_at") = CAST(? AS TEXT)'
467
+ );
468
+ });
469
+
470
+ test('aggregates sum/avg/min/max read the aggregate column', async () => {
471
+ const db = mockDb({ first: () => ({ aggregate: 12.5 }) });
472
+ assert.equal(await table(db, 't').sum('amount'), 12.5);
473
+ assert.equal(await table(db, 't').avg('amount'), 12.5);
474
+ assert.equal(await table(db, 't').min('amount'), 12.5);
475
+ assert.equal(await table(db, 't').max('amount'), 12.5);
476
+ const nullDb = mockDb({ first: () => ({ aggregate: null }) });
477
+ // Laravel's sum(): `return $result ?: 0;` - an empty set answers 0.
478
+ assert.equal(await table(nullDb, 't').sum('amount'), 0);
479
+ // avg/min/max keep the null (Laravel only coerces sum).
480
+ assert.equal(await table(nullDb, 't').avg('amount'), null);
481
+ assert.equal(await table(nullDb, 't').min('amount'), null);
482
+ assert.equal(await table(nullDb, 't').max('amount'), null);
483
+ });
484
+
485
+ test('upsert builds ON CONFLICT DO UPDATE / DO NOTHING', async () => {
486
+ const calls = [];
487
+ const db = mockDb({ run: (sql, params) => { calls.push({ sql, params }); return { success: true, meta: { changes: 2 } }; } });
488
+ const r = await table(db, 'users').upsert([{ id: 1, name: 'A' }, { id: 2, name: 'B' }], ['id'], ['name']);
489
+ assert.equal(r.changes, 2);
490
+ assert.equal(calls[0].sql, 'INSERT INTO "users" ("id", "name") VALUES (?, ?), (?, ?) ON CONFLICT ("id") DO UPDATE SET "name" = excluded."name"');
491
+ assert.deepEqual(calls[0].params, [1, 'A', 2, 'B']);
492
+ await table(db, 'users').upsert({ id: 1, name: 'A' }, 'id', []);
493
+ assert.equal(calls[1].sql, 'INSERT INTO "users" ("id", "name") VALUES (?, ?) ON CONFLICT ("id") DO NOTHING');
494
+ });
495
+
496
+ test('insertGetId / truncate / increment / decrement', async () => {
497
+ const calls = [];
498
+ const db = mockDb({ run: (sql, params) => { calls.push({ sql, params }); return { success: true, meta: { changes: 1, last_row_id: 9 } }; } });
499
+ assert.equal(await table(db, 't').insertGetId({ name: 'A' }), 9);
500
+ assert.match(calls[0].sql, /^INSERT INTO "t"/);
501
+ const tr = await table(db, 't').truncate();
502
+ assert.equal(tr.success, true);
503
+ assert.equal(calls[1].sql, 'DELETE FROM "t"');
504
+ await table(db, 't').where('id', 3).increment('views', 2);
505
+ assert.equal(calls[2].sql, 'UPDATE "t" SET "views" = "views" + ? WHERE "id" = ?');
506
+ assert.deepEqual(calls[2].params, [2, 3]);
507
+ await table(db, 't').where('id', 3).decrement('views', 2);
508
+ assert.deepEqual(calls[3].params, [-2, 3]);
509
+ });
510
+
511
+ test('updateOrInsert updates when found, inserts otherwise', async () => {
512
+ const calls = [];
513
+ let found = { id: 1, name: 'A' };
514
+ const db = mockDb({
515
+ all: () => (found ? { results: [{ ...found }] } : { results: [] }),
516
+ run: (sql, params) => { calls.push({ sql, params }); return { success: true, meta: { changes: 1, last_row_id: 5 } }; },
517
+ });
518
+ const upd = await table(db, 't').updateOrInsert({ id: 1 }, { name: 'B' });
519
+ assert.equal(upd.changes, 1);
520
+ assert.match(calls[0].sql, /^UPDATE "t" SET "name" = \? WHERE "id" = \?$/);
521
+ assert.deepEqual(calls[0].params, ['B', 1]);
522
+
523
+ found = null;
524
+ await table(db, 't').updateOrInsert({ id: 2 }, { name: 'C' });
525
+ assert.match(calls[1].sql, /^INSERT INTO "t" \("id", "name"\) VALUES \(\?, \?\)$/);
526
+ assert.deepEqual(calls[1].params, [2, 'C']);
527
+ });
528
+
529
+ test('chunk paginates until exhausted', async () => {
530
+ const rows = [{ id: 1 }, { id: 2 }, { id: 3 }];
531
+ const db = mockDb({
532
+ all: (sql, params) => {
533
+ const limit = params[params.length - 2];
534
+ const offset = params[params.length - 1];
535
+ return { results: rows.slice(offset, offset + limit) };
536
+ },
537
+ });
538
+ const seen = [];
539
+ await table(db, 't').chunk(2, (batch, page) => { seen.push([page, batch.length]); });
540
+ assert.deepEqual(seen, [[1, 2], [2, 1]]);
541
+ });
542
+
543
+ class CastModel extends Model {
544
+ static table = 'items';
545
+ static fillable = ['name', 'meta', 'price', 'secret', 'first_name'];
546
+ static casts = { meta: 'json', price: 'decimal:2', secret: 'int' };
547
+ static hidden = ['secret'];
548
+ static appends = ['label'];
549
549
  // A compiled accessor is `function (rt, value)`: the kernel hands the runtime in.
550
- getLabelAttribute(rt) { return (this.attributes.name || '') + '!'; }
551
- getFirstNameAttribute(rt, value) { return String(value || '').toUpperCase(); }
552
- setFirstNameAttribute(value) { return String(value).trim(); }
553
- }
554
-
555
- class SoftModel extends Model {
556
- static table = 'notes';
557
- static fillable = ['title'];
558
- static softDeletes = true;
559
- }
560
-
561
- test('Model casts, accessors/mutators, hidden/visible/appends', () => {
562
- const m = new CastModel({ id: 1, name: 'Ada', meta: '{"a":1}', price: '3.5', secret: 7, first_name: 'ada' });
563
- assert.deepEqual(m.get('meta'), { a: 1 });
564
- assert.equal(m.get('price'), '3.50');
565
- assert.equal(m.get('secret'), 7);
566
- assert.equal(m.get('first_name'), 'ADA');
567
- m.set('first_name', ' grace ');
568
- assert.equal(m.attributes.first_name, 'grace');
569
- const json = m.toJSON();
570
- assert.equal(Object.prototype.hasOwnProperty.call(json, 'secret'), false);
571
- assert.equal(json.label, 'Ada!');
572
- assert.deepEqual(json.meta, { a: 1 });
573
- assert.equal(json.price, '3.50');
574
- });
575
-
576
- class HashedModel extends Model {
577
- static table = 'users';
578
- static fillable = ['name', 'password'];
579
- static casts = { password: 'hashed' };
580
- static hashRounds = 12;
581
- }
582
-
583
- class HashedLowCostModel extends Model {
584
- static table = 'users';
585
- static fillable = ['password'];
586
- static casts = { password: 'hashed' };
587
- static hashRounds = 4;
588
- }
589
-
590
- test('Model fillable blocks non-fillable mass assignment', () => {
591
- const m = new HashedModel();
592
- m.fill({ name: 'Ada', password: null, is_admin: true });
593
- assert.equal(m.attributes.name, 'Ada');
594
- assert.equal(Object.prototype.hasOwnProperty.call(m.attributes, 'password'), true);
595
- assert.equal(m.attributes.password, null);
596
- assert.equal(Object.prototype.hasOwnProperty.call(m.attributes, 'is_admin'), false);
597
- // forceFill bypasses the guard (Eloquent parity).
598
- m.forceFill({ is_admin: true });
599
- assert.equal(m.attributes.is_admin, true);
600
- });
601
-
602
- test('Model hashed cast hashes plaintext at set-time with $2y$', async () => {
603
- const m = new HashedLowCostModel();
604
- m.set('password', 'secret');
605
- assert.equal(m.attributes.password.slice(0, 7), '$2y$04$');
606
- assert.equal(m.attributes.password.length, 60);
607
- assert.equal(await check('secret', m.attributes.password), true);
608
- assert.equal(await check('wrong', m.attributes.password), false);
609
-
610
- // fill()/create() go through set(), so the cast applies there too.
611
- const filled = new HashedLowCostModel();
612
- filled.fill({ password: 'hunter2' });
613
- assert.equal(filled.attributes.password.slice(0, 7), '$2y$04$');
614
- assert.equal(await check('hunter2', filled.attributes.password), true);
615
- });
616
-
617
- test('Model hashed cast passes through valid bcrypt cost <= rounds', async () => {
618
- const m = new HashedModel();
619
- m.set('password', HASH10);
620
- assert.equal(m.attributes.password, HASH10);
621
- m.set('password', HASH12);
622
- assert.equal(m.attributes.password, HASH12);
623
- });
624
-
625
- test('Model hashed cast rejects cost > rounds with RuntimeException', () => {
626
- const m = new HashedModel();
627
- assert.throws(
628
- () => m.set('password', HASH14),
629
- (err) =>
630
- err.name === 'RuntimeException' &&
631
- err.phpClass === 'RuntimeException' &&
632
- err.message === "Could not verify the hashed value's configuration."
633
- );
634
- });
635
-
636
- test('Model hashed cast re-hashes $2a$/$2b$/truncated $2y$ like PHP 8.3', async () => {
637
- // PHP's password_get_info() reports algo=null for these, so Laravel's
638
- // Hash::isHashed() is false and the cast hashes the digest string itself.
639
- const m = new HashedLowCostModel();
640
- m.set('password', HASH12_A);
641
- assert.notEqual(m.attributes.password, HASH12_A);
642
- assert.equal(m.attributes.password.slice(0, 7), '$2y$04$');
643
- assert.equal(await check(HASH12_A, m.attributes.password), true);
644
-
645
- m.set('password', SHORT_2Y);
646
- assert.notEqual(m.attributes.password, SHORT_2Y);
647
- assert.equal(await check(SHORT_2Y, m.attributes.password), true);
648
- });
649
-
650
- test('Model hashed cast rejects argon2 (bcrypt-only verifier)', () => {
651
- const m = new HashedModel();
652
- assert.throws(
653
- () => m.set('password', ARGON),
654
- /Could not verify the hashed value's configuration\./
655
- );
656
- });
657
-
658
- test('Model hashed cast null/empty behavior and mutator priority', async () => {
659
- const m = new HashedModel();
660
- m.set('password', null);
661
- assert.equal(m.attributes.password, null);
662
- m.set('password', undefined);
663
- assert.equal(m.attributes.password, null);
664
- m.set('password', '');
665
- assert.equal(m.attributes.password.slice(0, 7), '$2y$12$');
666
- assert.equal(await check('', m.attributes.password), true);
667
-
668
- class MutatorModel extends Model {
669
- static table = 'users';
670
- static fillable = ['password'];
671
- static casts = { password: 'hashed' };
672
- static hashRounds = 12;
673
- setPasswordAttribute(value) {
674
- return 'mutated:' + value;
675
- }
676
- }
677
- const mm = new MutatorModel();
678
- mm.set('password', 'secret');
679
- assert.equal(mm.attributes.password, 'mutated:secret');
680
- });
681
-
682
- test('Model constructor bypasses casts (DB hydration parity)', () => {
683
- // Documented divergence: Eloquent's constructor uses setRawAttributes(), so
684
- // rows loaded from D1 must not be re-hashed. fill()/create()/update() apply casts.
685
- const m = new HashedModel({ password: 'plain' });
686
- assert.equal(m.attributes.password, 'plain');
687
- assert.notEqual(m.attributes.password.slice(0, 7), '$2y$12$');
688
- });
689
-
690
- test('makeSync is deterministic in shape and model uses configured rounds', async () => {
691
- const a = makeSync('x', { rounds: 4 });
692
- const b = makeSync('x', { rounds: 4 });
693
- assert.equal(a.slice(0, 7), '$2y$04$');
694
- assert.equal(b.slice(0, 7), '$2y$04$');
695
- assert.notEqual(a, b);
696
- const m = new HashedLowCostModel();
697
- m.set('password', 'x');
698
- assert.equal(m.attributes.password.slice(0, 7), '$2y$04$');
699
- });
700
-
701
-
702
- test('Model json cast is stored as a string', async () => {
703
- const calls = [];
704
- const db = mockDb({ run: (sql, params) => { calls.push({ sql, params }); return { success: true, meta: { changes: 1, last_row_id: 1 } }; } });
705
- await CastModel.create(db, { name: 'A', meta: { a: 1 } });
706
- assert.equal(calls[0].sql.startsWith('INSERT INTO "items"'), true);
707
- assert.notEqual(calls[0].params.indexOf('{"a":1}'), -1);
708
- });
709
-
710
- test('Model softDeletes: query filters, delete stamps, restore clears, forceDelete removes', async () => {
711
- const calls = [];
712
- const db = mockDb({
713
- all: () => ({ results: [] }),
714
- run: (sql, params) => { calls.push({ sql, params }); return { success: true, meta: { changes: 1 } }; },
715
- });
716
- assert.equal(SoftModel.query(db).toSql().sql, 'SELECT * FROM "notes" WHERE "deleted_at" IS NULL');
717
- assert.equal(SoftModel.withTrashed(db).toSql().sql, 'SELECT * FROM "notes"');
718
- assert.equal(SoftModel.onlyTrashed(db).toSql().sql, 'SELECT * FROM "notes" WHERE "deleted_at" IS NOT NULL');
719
-
720
- const m = new SoftModel({ id: 1, title: 'A' });
721
- assert.equal(m.trashed(), false);
722
- await m.delete(db);
723
- assert.equal(m.trashed(), true);
724
- // Laravel's runSoftDelete() (SoftDeletes.php:141-152) writes deleted_at AND
725
- // updated_at - the row's timestamp is the delete time.
726
- assert.match(calls[0].sql, /"deleted_at" = \?/);
727
- assert.match(calls[0].sql, /"updated_at" = \?/);
728
- await m.restore(db);
729
- assert.equal(m.trashed(), false);
730
- // The restore writes through save(): deleted_at = null and a fresh
731
- // updated_at.
732
- assert.equal(calls[1].params[0], null);
733
- assert.equal(calls[1].params[calls[1].params.length - 1], 1);
734
- await m.forceDelete(db);
735
- assert.equal(calls[2].sql, 'DELETE FROM "notes" WHERE "id" = ?');
736
- assert.equal(m.exists, false);
737
- });
738
-
739
- test('Model findOrFail / firstOrFail', async () => {
740
- const hit = mockDb({ all: () => ({ results: [{ id: 1, name: 'A' }] }) });
741
- const miss = mockDb({ all: () => ({ results: [] }) });
742
- assert.equal((await RelModel.findOrFail(hit, 1)).get('id'), 1);
743
- await assert.rejects(() => RelModel.findOrFail(miss, 9), /not found/);
744
- assert.equal((await RelModel.firstOrFail(hit)).get('id'), 1);
745
- await assert.rejects(() => RelModel.firstOrFail(miss), /not found/);
746
- });
747
-
748
- // Table-aware mock: routes `all`/`first` to a store keyed by table name.
749
- function tableDb(store) {
750
- return {
751
- prepare(sql) {
752
- const pick = () => {
753
- for (const name of Object.keys(store)) if (sql.includes('"' + name + '"')) return store[name];
754
- return [];
755
- };
756
- return {
757
- params: [],
758
- bind(...p) {
759
- this.params = p;
760
- return this;
761
- },
762
- async all() { return { results: pick() }; },
763
- async first() { return pick()[0] || null; },
764
- async run() { return { success: true, meta: { changes: 1, last_row_id: 1 } }; },
765
- };
766
- },
767
- };
768
- }
769
-
770
- test('Model with() eager-loads declared hasMany relations', async () => {
771
- class Comment extends Model {}
772
- Comment.table = 'comments';
773
- class Post extends Model {}
774
- Post.table = 'posts';
775
- Post.relations = { comments: { type: 'hasMany', model: Comment } };
776
- const db = tableDb({
777
- posts: [{ id: 1, title: 'a' }, { id: 2, title: 'b' }],
778
- comments: [{ id: 10, post_id: 1, body: 'x' }, { id: 11, post_id: 1, body: 'y' }, { id: 12, post_id: 2, body: 'z' }],
779
- });
780
- const posts = await Post.query(db).with('comments').get();
781
- assert.equal(posts.length, 2);
782
- assert.deepEqual(posts[0].getRelation('comments').all().map((c) => c.get('body')), ['x', 'y']);
783
- assert.deepEqual(posts[1].getRelation('comments').all().map((c) => c.get('body')), ['z']);
784
- assert.deepEqual(posts[0].toJSON().comments.map((c) => c.id), [10, 11]);
785
- });
786
-
787
- test('Model with() eager-loads belongsTo via default foreign key', async () => {
788
- class Author extends Model {}
789
- Author.table = 'authors';
790
- class Book extends Model {}
791
- Book.table = 'books';
792
- Book.relations = { author: { type: 'belongsTo', model: Author } };
793
- const db = tableDb({
794
- books: [{ id: 1, author_id: 7 }, { id: 2, author_id: 8 }, { id: 3, author_id: 9 }],
795
- authors: [{ id: 7, name: 'Ada' }, { id: 8, name: 'Bob' }],
796
- });
797
- const books = await Book.query(db).with('author').get();
798
- assert.equal(books[0].getRelation('author').get('name'), 'Ada');
799
- assert.equal(books[1].getRelation('author').get('name'), 'Bob');
800
- assert.equal(books[2].getRelation('author'), null);
801
- });
802
-
803
- test('Model with() eager-loads hasOne as a single row', async () => {
804
- class Profile extends Model {}
805
- Profile.table = 'profiles';
806
- class User extends Model {}
807
- User.table = 'users';
808
- User.relations = { profile: { type: 'hasOne', model: Profile } };
809
- const db = tableDb({
810
- users: [{ id: 1 }, { id: 2 }],
811
- profiles: [{ id: 5, user_id: 1, bio: 'hi' }],
812
- });
813
- const users = await User.query(db).with('profile').get();
814
- assert.equal(users[0].getRelation('profile').get('bio'), 'hi');
815
- assert.equal(users[1].getRelation('profile'), null);
816
- });
817
-
818
- test('Model related()/load() resolve declared relations lazily', async () => {
819
- class Comment extends Model {}
820
- Comment.table = 'comments';
821
- class Post extends Model {}
822
- Post.table = 'posts';
823
- Post.relations = { comments: { type: 'hasMany', model: Comment } };
824
- const db = tableDb({ posts: [{ id: 1 }], comments: [{ id: 10, post_id: 1 }] });
825
- const p = new Post({ id: 1 });
826
- assert.equal(p.relationLoaded('comments'), false);
827
- const rel = await p.related(db, 'comments');
828
- assert.deepEqual(rel.all().map((c) => c.get('id')), [10]);
829
- assert.equal(p.relationLoaded('comments'), true);
830
- await assert.rejects(() => p.related(db, 'nope'), /unknown relation/);
831
- const p2 = new Post({ id: 1 });
832
- await p2.load(db, 'comments');
833
- assert.equal(p2.relationLoaded('comments'), true);
834
- });
835
-
836
- test('Model with() rejects unknown relations', async () => {
837
- class Thing extends Model {}
838
- Thing.table = 'things';
839
- const db = tableDb({ things: [{ id: 1 }] });
840
- await assert.rejects(() => Thing.query(db).with('nope').get(), /unknown relation/);
841
- });
842
-
843
- test('Model query().scope() runs local scopes', async () => {
844
- class PostScope extends Model {}
845
- PostScope.table = 'posts';
846
- PostScope.prototype.scopePublished = (q) => q.where('published', 1);
847
- const db = tableDb({ posts: [] });
848
- const { sql, params } = PostScope.query(db).scope('published').toSql();
849
- assert.match(sql, /"published" = \?/);
850
- assert.deepEqual(params, [1]);
851
- assert.throws(() => PostScope.query(db).scope('nope'), /unknown scope/);
852
- });
853
-
854
- test('whereExists with correlated column equality', () => {
855
- const { sql, params } = table(mockDb(), 'users')
856
- .whereExists((q) => q.from('posts').selectRaw('1').whereColumn('posts.user_id', 'users.id'))
857
- .toSql();
858
- assert.match(sql, /where exists/i);
859
- assert.match(sql, /select 1 from "posts"/i);
860
- assert.match(sql, /"posts"\."user_id" = "users"\."id"/);
861
- assert.equal(params.length, 0);
862
- });
863
-
864
- test('whereNotExists with numeric bound merges params', () => {
865
- const { sql, params } = table(mockDb(), 'users')
866
- .where('active', 1)
867
- .whereNotExists((q) => q.from('posts').where('posts.user_id', 99))
868
- .toSql();
869
- assert.match(sql, /not exists/i);
870
- assert.deepEqual(params, [1, 99]);
871
- });
872
-
873
- test('orWhereExists variant renders OR EXISTS', () => {
874
- const { sql, params } = table(mockDb(), 'users')
875
- .where('a', 1)
876
- .orWhereExists((q) => q.from('posts').where('x', 2))
877
- .toSql();
878
- assert.match(sql, / or exists /i);
879
- assert.deepEqual(params, [1, 2]);
880
- });
881
-
882
- test('whereExists throws on non-function argument', () => {
883
- assert.throws(() => table(mockDb(), 't').whereExists('nope'), /expects a function/);
884
- assert.throws(() => table(mockDb(), 't').whereNotExists(null), /expects a function/);
885
- });
886
-
887
- test('union of two simple selects concatenates sql and params', () => {
888
- const q1 = table(mockDb(), 'users').select('id').where('active', 1);
889
- const q2 = table(mockDb(), 'admins').select('id').where('role', 'admin');
890
- const { sql, params } = q1.union(q2).toSql();
891
- assert.match(sql, /union/i);
892
- assert.match(sql, /\(SELECT "id" FROM "users"/);
893
- assert.match(sql, /\(SELECT "id" FROM "admins"/);
894
- assert.deepEqual(params, [1, 'admin']);
895
- });
896
-
897
- test('unionAll variant uses UNION ALL and supports chaining', () => {
898
- const q1 = table(mockDb(), 'a').select('id').where('x', 1);
899
- const q2 = table(mockDb(), 'b').select('id').where('y', 2);
900
- const q3 = table(mockDb(), 'c').select('id').where('z', 3);
901
- const { sql, params } = q1.unionAll(q2).union(q3).toSql();
902
- assert.match(sql, /union all/i);
903
- assert.match(sql, /union/i);
904
- assert.deepEqual(params, [1, 2, 3]);
905
- });
906
-
907
- test('union throws on non-QueryBuilder', () => {
908
- assert.throws(() => table(mockDb(), 't').union('nope'), /expects a QueryBuilder/);
909
- assert.throws(() => table(mockDb(), 't').unionAll(123), /expects a QueryBuilder/);
910
- });
911
-
912
- test('the model shim fails loud on a relation attribute (a relation is not compiled)', () => {
913
- class M extends Model {}
914
- M.notCompiled = ['comments'];
915
- const m = new M({ id: 1, title: 'x' });
916
- assert.throws(() => m.get('comments'), /not compiled/);
917
- assert.equal(m.get('title'), 'x');
918
- });
919
-
920
- test('where(col, null) is a where-null clause (Laravel: IS NULL, never `= NULL`)', () => {
921
- // Laravel's where() (Query/Builder.php:988-991): a NULL value is not a
922
- // comparison - `col = NULL` matches no row - it is `IS NULL`, and '!=' /
923
- // '<>' make it `IS NOT NULL`.
924
- let sql = null;
925
- const db = mockDb({ all: (s) => { sql = s; return { results: [] }; } });
926
- table(db, 'posts').where('deleted_at', null).get();
927
- assert.match(sql, /"deleted_at" IS NULL/);
928
- sql = null;
929
- table(db, 'posts').where('deleted_at', '=', null).get();
930
- assert.match(sql, /"deleted_at" IS NULL/);
931
- sql = null;
932
- table(db, 'posts').where('deleted_at', '!=', null).get();
933
- assert.match(sql, /"deleted_at" IS NOT NULL/);
934
- sql = null;
935
- table(db, 'posts').where('deleted_at', '<>', null).get();
936
- assert.match(sql, /"deleted_at" IS NOT NULL/);
937
- // An operator that cannot compare with NULL is an illegal combination
938
- // (Laravel's invalidOperatorAndValue: a NULL value with an operator outside
939
- // ['=', '<=>', '<>', '!=']).
940
- assert.throws(
941
- () => table(db, 'posts').where('deleted_at', '>', null),
942
- /Illegal operator and value combination/,
943
- );
944
- // '<=>' is one of Laravel's operators: with a NULL value it is IS NULL.
945
- sql = null;
946
- table(db, 'posts').where('deleted_at', '<=>', null).get();
947
- assert.match(sql, /"deleted_at" IS NULL/);
948
- // 'IS'/'IS NOT' are operators too (Laravel's $operators): with a value they
949
- // render `col IS ?` (SQLite supports it), with NULL they are an illegal
950
- // combination.
951
- sql = null;
952
- table(db, 'posts').where('deleted_at', 'IS', 5).get();
953
- assert.match(sql, /"deleted_at" IS \?/);
954
- assert.throws(
955
- () => table(db, 'posts').where('deleted_at', 'IS', null),
956
- /Illegal operator and value combination/,
957
- );
958
- });
959
-
960
- test('the model casts are PHP\'s casts (probed against PHP 8.3)', () => {
961
- // PHP's casts, probed:
962
- // (bool) 'false' = true, '0' = false, '' = false, '0.0' = true
963
- // (int) '12abc' = 12, 'abc' = 0, '1.9' = 1, '1e3' = 1000
964
- // the decimal cast: BigDecimal::of($v)->toScale(2, HALF_UP) - the rounding
965
- // happens on the DECIMAL string ('2.675' -> '2.68', where toFixed() says
966
- // '2.67')
967
- class M extends Model {}
968
- M.casts = { b: 'bool', i: 'int', f: 'float', d: 'decimal:2', t: 'timestamp' };
969
- const m = new M({ b: 'false', i: '12abc', f: '1.9', d: '2.675', t: '2026-01-02 03:04:05' });
970
- assert.equal(m.get('b'), true);
971
- assert.equal(m.get('i'), 12);
972
- assert.equal(m.get('f'), 1.9);
973
- assert.equal(m.get('d'), '2.68');
974
- assert.equal(m.get('t'), 1767323045);
975
- const m2 = new M({ b: '0', i: 'abc', d: '-2.675' });
976
- assert.equal(m2.get('b'), false);
977
- assert.equal(m2.get('i'), 0);
978
- assert.equal(m2.get('d'), '-2.68');
979
- const m3 = new M({ b: '0.0', i: '1e3', d: '1.005' });
980
- assert.equal(m3.get('b'), true);
981
- assert.equal(m3.get('i'), 1000);
982
- assert.equal(m3.get('d'), '1.01');
983
- });
984
-
985
- test('the model JSON serialises the date attributes like Carbon (ISO-8601)', () => {
986
- // Laravel serialises the timestamps (and the date casts) through
987
- // Carbon::toJSON() -> '2024-01-01T00:00:00.000000Z' (the model's JSON is
988
- // what a response carries).
989
- class M extends Model {}
990
- M.casts = { published_at: 'datetime' };
991
- const m = new M({
992
- id: 1,
993
- title: 'A',
994
- created_at: '2024-01-01 00:00:00',
995
- updated_at: '2024-01-02 03:04:05',
996
- published_at: '2024-01-03 00:00:00',
997
- });
998
- const json = m.toJSON();
999
- assert.equal(json.created_at, '2024-01-01T00:00:00.000000Z');
1000
- assert.equal(json.updated_at, '2024-01-02T03:04:05.000000Z');
1001
- assert.equal(json.published_at, '2024-01-03T00:00:00.000000Z');
1002
- assert.equal(json.title, 'A');
1003
- });
1004
-
1005
- test('the model flags a fresh insert (Laravel\'s wasRecentlyCreated)', async () => {
1006
- // Laravel sets $wasRecentlyCreated in the insert paths (performInsert /
1007
- // insertAndSetId), so a controller that returns a model it just saved
1008
- // answers 201 (Router::toResponse).
1009
- const db = mockDb({ run: () => ({ success: true, meta: { changes: 1, last_row_id: 7 } }) });
1010
- const fresh = new RelModel({ title: 'A' });
1011
- assert.equal(fresh.wasRecentlyCreated, false);
1012
- await fresh.save(db);
1013
- assert.equal(fresh.wasRecentlyCreated, true);
1014
- const existing = new RelModel({ id: 1, title: 'A' });
1015
- await existing.save(db);
1016
- assert.equal(existing.wasRecentlyCreated, false, 'an update is not a fresh insert');
1017
- });
1018
-
1019
- test('orderBy refuses a direction that is not asc/desc (Laravel throws)', () => {
1020
- // Laravel's orderBy (Query/Builder.php:3042-3062): 'Order direction must be
1021
- // "asc" or "desc".' - the edge used to answer any direction as ASC (a
1022
- // different row order, silently).
1023
- const dir = (d) => table(mockDb(), 'posts').orderBy('title', d).order[0].direction;
1024
- assert.equal(dir('asc'), 'ASC');
1025
- assert.equal(dir('desc'), 'DESC');
1026
- assert.equal(dir('DESC'), 'DESC');
1027
- assert.throws(() => table(mockDb(), 'posts').orderBy('title', 'sideways'), /Order direction must be/);
1028
- });
1029
-
1030
- test('sum() answers 0 on an empty set (Laravel: `$result ?: 0`)', async () => {
1031
- // Laravel's sum() (Query/Builder.php:4099-4104): `return $result ?: 0;` - an
1032
- // empty set (or an all-NULL column) answers 0, not null (the JSON body
1033
- // differed: null vs 0).
1034
- const db = mockDb({ first: () => ({ aggregate: null }) });
1035
- assert.equal(await table(db, 'posts').sum('views'), 0);
1036
- const db2 = mockDb({ first: () => ({ aggregate: '7' }) });
1037
- assert.equal(await table(db2, 'posts').sum('views'), 7);
1038
- });
1039
-
1040
- test('a Date is bound with the grammar date format (Laravel: Y-m-d H:i:s)', async () => {
1041
- // Laravel's Connection::prepareBindings(): a DateTimeInterface is bound with
1042
- // the grammar's date format - an ISO string never matches the stored TEXT
1043
- // column.
1044
- const calls = [];
1045
- const stmt = {
1046
- bind(...p) { calls.push({ sql: this.sql, params: p }); return this; },
1047
- run: async () => ({ success: true, meta: { changes: 1 } }),
1048
- all: async () => ({ results: [] }),
1049
- first: async () => null,
1050
- };
1051
- const db = { prepare(sql) { stmt.sql = sql; return stmt; } };
1052
- await table(db, 'posts').where('created_at', new Date('2024-01-02T03:04:05Z')).get();
1053
- assert.deepEqual(calls[0].params, ['2024-01-02 03:04:05']);
1054
- });
1055
-
1056
- test('find() with an array of ids returns the rows (Laravel: findMany)', async () => {
1057
- // Laravel's find() (Eloquent/Builder.php:607-616): an array of ids returns a
1058
- // COLLECTION of the found models; the edge bound the array as ONE value (a
1059
- // query that matched nothing).
1060
- const db = mockDb({ all: () => ({ results: [{ id: 1 }, { id: 3 }] }) });
1061
- class M extends Model {}
1062
- M.table = 'posts';
1063
- M.primaryKey = 'id';
1064
- const found = await M.find(db, [1, 3]);
1065
- assert.equal(Array.isArray(found), true, 'an array of ids returns a list');
1066
- assert.deepEqual(found.map((m) => m.get('id')), [1, 3]);
1067
- // findOrFail with an array names the missing ids.
1068
- await assert.rejects(() => M.findOrFail(db, [1, 9]), /not found: 9/);
1069
- });
550
+ getLabelAttribute(rt) { return (this.attributes.name || '') + '!'; }
551
+ getFirstNameAttribute(rt, value) { return String(value || '').toUpperCase(); }
552
+ setFirstNameAttribute(value) { return String(value).trim(); }
553
+ }
554
+
555
+ class SoftModel extends Model {
556
+ static table = 'notes';
557
+ static fillable = ['title'];
558
+ static softDeletes = true;
559
+ }
560
+
561
+ test('Model casts, accessors/mutators, hidden/visible/appends', () => {
562
+ const m = new CastModel({ id: 1, name: 'Ada', meta: '{"a":1}', price: '3.5', secret: 7, first_name: 'ada' });
563
+ assert.deepEqual(m.get('meta'), { a: 1 });
564
+ assert.equal(m.get('price'), '3.50');
565
+ assert.equal(m.get('secret'), 7);
566
+ assert.equal(m.get('first_name'), 'ADA');
567
+ m.set('first_name', ' grace ');
568
+ assert.equal(m.attributes.first_name, 'grace');
569
+ const json = m.toJSON();
570
+ assert.equal(Object.prototype.hasOwnProperty.call(json, 'secret'), false);
571
+ assert.equal(json.label, 'Ada!');
572
+ assert.deepEqual(json.meta, { a: 1 });
573
+ assert.equal(json.price, '3.50');
574
+ });
575
+
576
+ class HashedModel extends Model {
577
+ static table = 'users';
578
+ static fillable = ['name', 'password'];
579
+ static casts = { password: 'hashed' };
580
+ static hashRounds = 12;
581
+ }
582
+
583
+ class HashedLowCostModel extends Model {
584
+ static table = 'users';
585
+ static fillable = ['password'];
586
+ static casts = { password: 'hashed' };
587
+ static hashRounds = 4;
588
+ }
589
+
590
+ test('Model fillable blocks non-fillable mass assignment', () => {
591
+ const m = new HashedModel();
592
+ m.fill({ name: 'Ada', password: null, is_admin: true });
593
+ assert.equal(m.attributes.name, 'Ada');
594
+ assert.equal(Object.prototype.hasOwnProperty.call(m.attributes, 'password'), true);
595
+ assert.equal(m.attributes.password, null);
596
+ assert.equal(Object.prototype.hasOwnProperty.call(m.attributes, 'is_admin'), false);
597
+ // forceFill bypasses the guard (Eloquent parity).
598
+ m.forceFill({ is_admin: true });
599
+ assert.equal(m.attributes.is_admin, true);
600
+ });
601
+
602
+ test('Model hashed cast hashes plaintext at set-time with $2y$', async () => {
603
+ const m = new HashedLowCostModel();
604
+ m.set('password', 'secret');
605
+ assert.equal(m.attributes.password.slice(0, 7), '$2y$04$');
606
+ assert.equal(m.attributes.password.length, 60);
607
+ assert.equal(await check('secret', m.attributes.password), true);
608
+ assert.equal(await check('wrong', m.attributes.password), false);
609
+
610
+ // fill()/create() go through set(), so the cast applies there too.
611
+ const filled = new HashedLowCostModel();
612
+ filled.fill({ password: 'hunter2' });
613
+ assert.equal(filled.attributes.password.slice(0, 7), '$2y$04$');
614
+ assert.equal(await check('hunter2', filled.attributes.password), true);
615
+ });
616
+
617
+ test('Model hashed cast passes through valid bcrypt cost <= rounds', async () => {
618
+ const m = new HashedModel();
619
+ m.set('password', HASH10);
620
+ assert.equal(m.attributes.password, HASH10);
621
+ m.set('password', HASH12);
622
+ assert.equal(m.attributes.password, HASH12);
623
+ });
624
+
625
+ test('Model hashed cast rejects cost > rounds with RuntimeException', () => {
626
+ const m = new HashedModel();
627
+ assert.throws(
628
+ () => m.set('password', HASH14),
629
+ (err) =>
630
+ err.name === 'RuntimeException' &&
631
+ err.phpClass === 'RuntimeException' &&
632
+ err.message === "Could not verify the hashed value's configuration."
633
+ );
634
+ });
635
+
636
+ test('Model hashed cast re-hashes $2a$/$2b$/truncated $2y$ like PHP 8.3', async () => {
637
+ // PHP's password_get_info() reports algo=null for these, so Laravel's
638
+ // Hash::isHashed() is false and the cast hashes the digest string itself.
639
+ const m = new HashedLowCostModel();
640
+ m.set('password', HASH12_A);
641
+ assert.notEqual(m.attributes.password, HASH12_A);
642
+ assert.equal(m.attributes.password.slice(0, 7), '$2y$04$');
643
+ assert.equal(await check(HASH12_A, m.attributes.password), true);
644
+
645
+ m.set('password', SHORT_2Y);
646
+ assert.notEqual(m.attributes.password, SHORT_2Y);
647
+ assert.equal(await check(SHORT_2Y, m.attributes.password), true);
648
+ });
649
+
650
+ test('Model hashed cast rejects argon2 (bcrypt-only verifier)', () => {
651
+ const m = new HashedModel();
652
+ assert.throws(
653
+ () => m.set('password', ARGON),
654
+ /Could not verify the hashed value's configuration\./
655
+ );
656
+ });
657
+
658
+ test('Model hashed cast null/empty behavior and mutator priority', async () => {
659
+ const m = new HashedModel();
660
+ m.set('password', null);
661
+ assert.equal(m.attributes.password, null);
662
+ m.set('password', undefined);
663
+ assert.equal(m.attributes.password, null);
664
+ m.set('password', '');
665
+ assert.equal(m.attributes.password.slice(0, 7), '$2y$12$');
666
+ assert.equal(await check('', m.attributes.password), true);
667
+
668
+ class MutatorModel extends Model {
669
+ static table = 'users';
670
+ static fillable = ['password'];
671
+ static casts = { password: 'hashed' };
672
+ static hashRounds = 12;
673
+ setPasswordAttribute(value) {
674
+ return 'mutated:' + value;
675
+ }
676
+ }
677
+ const mm = new MutatorModel();
678
+ mm.set('password', 'secret');
679
+ assert.equal(mm.attributes.password, 'mutated:secret');
680
+ });
681
+
682
+ test('Model constructor bypasses casts (DB hydration parity)', () => {
683
+ // Documented divergence: Eloquent's constructor uses setRawAttributes(), so
684
+ // rows loaded from D1 must not be re-hashed. fill()/create()/update() apply casts.
685
+ const m = new HashedModel({ password: 'plain' });
686
+ assert.equal(m.attributes.password, 'plain');
687
+ assert.notEqual(m.attributes.password.slice(0, 7), '$2y$12$');
688
+ });
689
+
690
+ test('makeSync is deterministic in shape and model uses configured rounds', async () => {
691
+ const a = makeSync('x', { rounds: 4 });
692
+ const b = makeSync('x', { rounds: 4 });
693
+ assert.equal(a.slice(0, 7), '$2y$04$');
694
+ assert.equal(b.slice(0, 7), '$2y$04$');
695
+ assert.notEqual(a, b);
696
+ const m = new HashedLowCostModel();
697
+ m.set('password', 'x');
698
+ assert.equal(m.attributes.password.slice(0, 7), '$2y$04$');
699
+ });
700
+
701
+
702
+ test('Model json cast is stored as a string', async () => {
703
+ const calls = [];
704
+ const db = mockDb({ run: (sql, params) => { calls.push({ sql, params }); return { success: true, meta: { changes: 1, last_row_id: 1 } }; } });
705
+ await CastModel.create(db, { name: 'A', meta: { a: 1 } });
706
+ assert.equal(calls[0].sql.startsWith('INSERT INTO "items"'), true);
707
+ assert.notEqual(calls[0].params.indexOf('{"a":1}'), -1);
708
+ });
709
+
710
+ test('Model softDeletes: query filters, delete stamps, restore clears, forceDelete removes', async () => {
711
+ const calls = [];
712
+ const db = mockDb({
713
+ all: () => ({ results: [] }),
714
+ run: (sql, params) => { calls.push({ sql, params }); return { success: true, meta: { changes: 1 } }; },
715
+ });
716
+ assert.equal(SoftModel.query(db).toSql().sql, 'SELECT * FROM "notes" WHERE "deleted_at" IS NULL');
717
+ assert.equal(SoftModel.withTrashed(db).toSql().sql, 'SELECT * FROM "notes"');
718
+ assert.equal(SoftModel.onlyTrashed(db).toSql().sql, 'SELECT * FROM "notes" WHERE "deleted_at" IS NOT NULL');
719
+
720
+ const m = new SoftModel({ id: 1, title: 'A' });
721
+ assert.equal(m.trashed(), false);
722
+ await m.delete(db);
723
+ assert.equal(m.trashed(), true);
724
+ // Laravel's runSoftDelete() (SoftDeletes.php:141-152) writes deleted_at AND
725
+ // updated_at - the row's timestamp is the delete time.
726
+ assert.match(calls[0].sql, /"deleted_at" = \?/);
727
+ assert.match(calls[0].sql, /"updated_at" = \?/);
728
+ await m.restore(db);
729
+ assert.equal(m.trashed(), false);
730
+ // The restore writes through save(): deleted_at = null and a fresh
731
+ // updated_at.
732
+ assert.equal(calls[1].params[0], null);
733
+ assert.equal(calls[1].params[calls[1].params.length - 1], 1);
734
+ await m.forceDelete(db);
735
+ assert.equal(calls[2].sql, 'DELETE FROM "notes" WHERE "id" = ?');
736
+ assert.equal(m.exists, false);
737
+ });
738
+
739
+ test('Model findOrFail / firstOrFail', async () => {
740
+ const hit = mockDb({ all: () => ({ results: [{ id: 1, name: 'A' }] }) });
741
+ const miss = mockDb({ all: () => ({ results: [] }) });
742
+ assert.equal((await RelModel.findOrFail(hit, 1)).get('id'), 1);
743
+ await assert.rejects(() => RelModel.findOrFail(miss, 9), /not found/);
744
+ assert.equal((await RelModel.firstOrFail(hit)).get('id'), 1);
745
+ await assert.rejects(() => RelModel.firstOrFail(miss), /not found/);
746
+ });
747
+
748
+ // Table-aware mock: routes `all`/`first` to a store keyed by table name.
749
+ function tableDb(store) {
750
+ return {
751
+ prepare(sql) {
752
+ const pick = () => {
753
+ for (const name of Object.keys(store)) if (sql.includes('"' + name + '"')) return store[name];
754
+ return [];
755
+ };
756
+ return {
757
+ params: [],
758
+ bind(...p) {
759
+ this.params = p;
760
+ return this;
761
+ },
762
+ async all() { return { results: pick() }; },
763
+ async first() { return pick()[0] || null; },
764
+ async run() { return { success: true, meta: { changes: 1, last_row_id: 1 } }; },
765
+ };
766
+ },
767
+ };
768
+ }
769
+
770
+ test('Model with() eager-loads declared hasMany relations', async () => {
771
+ class Comment extends Model {}
772
+ Comment.table = 'comments';
773
+ class Post extends Model {}
774
+ Post.table = 'posts';
775
+ Post.relations = { comments: { type: 'hasMany', model: Comment } };
776
+ const db = tableDb({
777
+ posts: [{ id: 1, title: 'a' }, { id: 2, title: 'b' }],
778
+ comments: [{ id: 10, post_id: 1, body: 'x' }, { id: 11, post_id: 1, body: 'y' }, { id: 12, post_id: 2, body: 'z' }],
779
+ });
780
+ const posts = await Post.query(db).with('comments').get();
781
+ assert.equal(posts.length, 2);
782
+ assert.deepEqual(posts[0].getRelation('comments').all().map((c) => c.get('body')), ['x', 'y']);
783
+ assert.deepEqual(posts[1].getRelation('comments').all().map((c) => c.get('body')), ['z']);
784
+ assert.deepEqual(posts[0].toJSON().comments.map((c) => c.id), [10, 11]);
785
+ });
786
+
787
+ test('Model with() eager-loads belongsTo via default foreign key', async () => {
788
+ class Author extends Model {}
789
+ Author.table = 'authors';
790
+ class Book extends Model {}
791
+ Book.table = 'books';
792
+ Book.relations = { author: { type: 'belongsTo', model: Author } };
793
+ const db = tableDb({
794
+ books: [{ id: 1, author_id: 7 }, { id: 2, author_id: 8 }, { id: 3, author_id: 9 }],
795
+ authors: [{ id: 7, name: 'Ada' }, { id: 8, name: 'Bob' }],
796
+ });
797
+ const books = await Book.query(db).with('author').get();
798
+ assert.equal(books[0].getRelation('author').get('name'), 'Ada');
799
+ assert.equal(books[1].getRelation('author').get('name'), 'Bob');
800
+ assert.equal(books[2].getRelation('author'), null);
801
+ });
802
+
803
+ test('Model with() eager-loads hasOne as a single row', async () => {
804
+ class Profile extends Model {}
805
+ Profile.table = 'profiles';
806
+ class User extends Model {}
807
+ User.table = 'users';
808
+ User.relations = { profile: { type: 'hasOne', model: Profile } };
809
+ const db = tableDb({
810
+ users: [{ id: 1 }, { id: 2 }],
811
+ profiles: [{ id: 5, user_id: 1, bio: 'hi' }],
812
+ });
813
+ const users = await User.query(db).with('profile').get();
814
+ assert.equal(users[0].getRelation('profile').get('bio'), 'hi');
815
+ assert.equal(users[1].getRelation('profile'), null);
816
+ });
817
+
818
+ test('Model related()/load() resolve declared relations lazily', async () => {
819
+ class Comment extends Model {}
820
+ Comment.table = 'comments';
821
+ class Post extends Model {}
822
+ Post.table = 'posts';
823
+ Post.relations = { comments: { type: 'hasMany', model: Comment } };
824
+ const db = tableDb({ posts: [{ id: 1 }], comments: [{ id: 10, post_id: 1 }] });
825
+ const p = new Post({ id: 1 });
826
+ assert.equal(p.relationLoaded('comments'), false);
827
+ const rel = await p.related(db, 'comments');
828
+ assert.deepEqual(rel.all().map((c) => c.get('id')), [10]);
829
+ assert.equal(p.relationLoaded('comments'), true);
830
+ await assert.rejects(() => p.related(db, 'nope'), /unknown relation/);
831
+ const p2 = new Post({ id: 1 });
832
+ await p2.load(db, 'comments');
833
+ assert.equal(p2.relationLoaded('comments'), true);
834
+ });
835
+
836
+ test('Model with() rejects unknown relations', async () => {
837
+ class Thing extends Model {}
838
+ Thing.table = 'things';
839
+ const db = tableDb({ things: [{ id: 1 }] });
840
+ await assert.rejects(() => Thing.query(db).with('nope').get(), /unknown relation/);
841
+ });
842
+
843
+ test('Model query().scope() runs local scopes', async () => {
844
+ class PostScope extends Model {}
845
+ PostScope.table = 'posts';
846
+ PostScope.prototype.scopePublished = (q) => q.where('published', 1);
847
+ const db = tableDb({ posts: [] });
848
+ const { sql, params } = PostScope.query(db).scope('published').toSql();
849
+ assert.match(sql, /"published" = \?/);
850
+ assert.deepEqual(params, [1]);
851
+ assert.throws(() => PostScope.query(db).scope('nope'), /unknown scope/);
852
+ });
853
+
854
+ test('whereExists with correlated column equality', () => {
855
+ const { sql, params } = table(mockDb(), 'users')
856
+ .whereExists((q) => q.from('posts').selectRaw('1').whereColumn('posts.user_id', 'users.id'))
857
+ .toSql();
858
+ assert.match(sql, /where exists/i);
859
+ assert.match(sql, /select 1 from "posts"/i);
860
+ assert.match(sql, /"posts"\."user_id" = "users"\."id"/);
861
+ assert.equal(params.length, 0);
862
+ });
863
+
864
+ test('whereNotExists with numeric bound merges params', () => {
865
+ const { sql, params } = table(mockDb(), 'users')
866
+ .where('active', 1)
867
+ .whereNotExists((q) => q.from('posts').where('posts.user_id', 99))
868
+ .toSql();
869
+ assert.match(sql, /not exists/i);
870
+ assert.deepEqual(params, [1, 99]);
871
+ });
872
+
873
+ test('orWhereExists variant renders OR EXISTS', () => {
874
+ const { sql, params } = table(mockDb(), 'users')
875
+ .where('a', 1)
876
+ .orWhereExists((q) => q.from('posts').where('x', 2))
877
+ .toSql();
878
+ assert.match(sql, / or exists /i);
879
+ assert.deepEqual(params, [1, 2]);
880
+ });
881
+
882
+ test('whereExists throws on non-function argument', () => {
883
+ assert.throws(() => table(mockDb(), 't').whereExists('nope'), /expects a function/);
884
+ assert.throws(() => table(mockDb(), 't').whereNotExists(null), /expects a function/);
885
+ });
886
+
887
+ test('union of two simple selects concatenates sql and params', () => {
888
+ const q1 = table(mockDb(), 'users').select('id').where('active', 1);
889
+ const q2 = table(mockDb(), 'admins').select('id').where('role', 'admin');
890
+ const { sql, params } = q1.union(q2).toSql();
891
+ assert.match(sql, /union/i);
892
+ assert.match(sql, /\(SELECT "id" FROM "users"/);
893
+ assert.match(sql, /\(SELECT "id" FROM "admins"/);
894
+ assert.deepEqual(params, [1, 'admin']);
895
+ });
896
+
897
+ test('unionAll variant uses UNION ALL and supports chaining', () => {
898
+ const q1 = table(mockDb(), 'a').select('id').where('x', 1);
899
+ const q2 = table(mockDb(), 'b').select('id').where('y', 2);
900
+ const q3 = table(mockDb(), 'c').select('id').where('z', 3);
901
+ const { sql, params } = q1.unionAll(q2).union(q3).toSql();
902
+ assert.match(sql, /union all/i);
903
+ assert.match(sql, /union/i);
904
+ assert.deepEqual(params, [1, 2, 3]);
905
+ });
906
+
907
+ test('union throws on non-QueryBuilder', () => {
908
+ assert.throws(() => table(mockDb(), 't').union('nope'), /expects a QueryBuilder/);
909
+ assert.throws(() => table(mockDb(), 't').unionAll(123), /expects a QueryBuilder/);
910
+ });
911
+
912
+ test('the model shim fails loud on a relation attribute (a relation is not compiled)', () => {
913
+ class M extends Model {}
914
+ M.notCompiled = ['comments'];
915
+ const m = new M({ id: 1, title: 'x' });
916
+ assert.throws(() => m.get('comments'), /not compiled/);
917
+ assert.equal(m.get('title'), 'x');
918
+ });
919
+
920
+ test('where(col, null) is a where-null clause (Laravel: IS NULL, never `= NULL`)', () => {
921
+ // Laravel's where() (Query/Builder.php:988-991): a NULL value is not a
922
+ // comparison - `col = NULL` matches no row - it is `IS NULL`, and '!=' /
923
+ // '<>' make it `IS NOT NULL`.
924
+ let sql = null;
925
+ const db = mockDb({ all: (s) => { sql = s; return { results: [] }; } });
926
+ table(db, 'posts').where('deleted_at', null).get();
927
+ assert.match(sql, /"deleted_at" IS NULL/);
928
+ sql = null;
929
+ table(db, 'posts').where('deleted_at', '=', null).get();
930
+ assert.match(sql, /"deleted_at" IS NULL/);
931
+ sql = null;
932
+ table(db, 'posts').where('deleted_at', '!=', null).get();
933
+ assert.match(sql, /"deleted_at" IS NOT NULL/);
934
+ sql = null;
935
+ table(db, 'posts').where('deleted_at', '<>', null).get();
936
+ assert.match(sql, /"deleted_at" IS NOT NULL/);
937
+ // An operator that cannot compare with NULL is an illegal combination
938
+ // (Laravel's invalidOperatorAndValue: a NULL value with an operator outside
939
+ // ['=', '<=>', '<>', '!=']).
940
+ assert.throws(
941
+ () => table(db, 'posts').where('deleted_at', '>', null),
942
+ /Illegal operator and value combination/,
943
+ );
944
+ // '<=>' is one of Laravel's operators: with a NULL value it is IS NULL.
945
+ sql = null;
946
+ table(db, 'posts').where('deleted_at', '<=>', null).get();
947
+ assert.match(sql, /"deleted_at" IS NULL/);
948
+ // 'IS'/'IS NOT' are operators too (Laravel's $operators): with a value they
949
+ // render `col IS ?` (SQLite supports it), with NULL they are an illegal
950
+ // combination.
951
+ sql = null;
952
+ table(db, 'posts').where('deleted_at', 'IS', 5).get();
953
+ assert.match(sql, /"deleted_at" IS \?/);
954
+ assert.throws(
955
+ () => table(db, 'posts').where('deleted_at', 'IS', null),
956
+ /Illegal operator and value combination/,
957
+ );
958
+ });
959
+
960
+ test('the model casts are PHP\'s casts (probed against PHP 8.3)', () => {
961
+ // PHP's casts, probed:
962
+ // (bool) 'false' = true, '0' = false, '' = false, '0.0' = true
963
+ // (int) '12abc' = 12, 'abc' = 0, '1.9' = 1, '1e3' = 1000
964
+ // the decimal cast: BigDecimal::of($v)->toScale(2, HALF_UP) - the rounding
965
+ // happens on the DECIMAL string ('2.675' -> '2.68', where toFixed() says
966
+ // '2.67')
967
+ class M extends Model {}
968
+ M.casts = { b: 'bool', i: 'int', f: 'float', d: 'decimal:2', t: 'timestamp' };
969
+ const m = new M({ b: 'false', i: '12abc', f: '1.9', d: '2.675', t: '2026-01-02 03:04:05' });
970
+ assert.equal(m.get('b'), true);
971
+ assert.equal(m.get('i'), 12);
972
+ assert.equal(m.get('f'), 1.9);
973
+ assert.equal(m.get('d'), '2.68');
974
+ assert.equal(m.get('t'), 1767323045);
975
+ const m2 = new M({ b: '0', i: 'abc', d: '-2.675' });
976
+ assert.equal(m2.get('b'), false);
977
+ assert.equal(m2.get('i'), 0);
978
+ assert.equal(m2.get('d'), '-2.68');
979
+ const m3 = new M({ b: '0.0', i: '1e3', d: '1.005' });
980
+ assert.equal(m3.get('b'), true);
981
+ assert.equal(m3.get('i'), 1000);
982
+ assert.equal(m3.get('d'), '1.01');
983
+ });
984
+
985
+ test('the model JSON serialises the date attributes like Carbon (ISO-8601)', () => {
986
+ // Laravel serialises the timestamps (and the date casts) through
987
+ // Carbon::toJSON() -> '2024-01-01T00:00:00.000000Z' (the model's JSON is
988
+ // what a response carries).
989
+ class M extends Model {}
990
+ M.casts = { published_at: 'datetime' };
991
+ const m = new M({
992
+ id: 1,
993
+ title: 'A',
994
+ created_at: '2024-01-01 00:00:00',
995
+ updated_at: '2024-01-02 03:04:05',
996
+ published_at: '2024-01-03 00:00:00',
997
+ });
998
+ const json = m.toJSON();
999
+ assert.equal(json.created_at, '2024-01-01T00:00:00.000000Z');
1000
+ assert.equal(json.updated_at, '2024-01-02T03:04:05.000000Z');
1001
+ assert.equal(json.published_at, '2024-01-03T00:00:00.000000Z');
1002
+ assert.equal(json.title, 'A');
1003
+ });
1004
+
1005
+ test('the model flags a fresh insert (Laravel\'s wasRecentlyCreated)', async () => {
1006
+ // Laravel sets $wasRecentlyCreated in the insert paths (performInsert /
1007
+ // insertAndSetId), so a controller that returns a model it just saved
1008
+ // answers 201 (Router::toResponse).
1009
+ const db = mockDb({ run: () => ({ success: true, meta: { changes: 1, last_row_id: 7 } }) });
1010
+ const fresh = new RelModel({ title: 'A' });
1011
+ assert.equal(fresh.wasRecentlyCreated, false);
1012
+ await fresh.save(db);
1013
+ assert.equal(fresh.wasRecentlyCreated, true);
1014
+ const existing = new RelModel({ id: 1, title: 'A' });
1015
+ await existing.save(db);
1016
+ assert.equal(existing.wasRecentlyCreated, false, 'an update is not a fresh insert');
1017
+ });
1018
+
1019
+ test('orderBy refuses a direction that is not asc/desc (Laravel throws)', () => {
1020
+ // Laravel's orderBy (Query/Builder.php:3042-3062): 'Order direction must be
1021
+ // "asc" or "desc".' - the edge used to answer any direction as ASC (a
1022
+ // different row order, silently).
1023
+ const dir = (d) => table(mockDb(), 'posts').orderBy('title', d).order[0].direction;
1024
+ assert.equal(dir('asc'), 'ASC');
1025
+ assert.equal(dir('desc'), 'DESC');
1026
+ assert.equal(dir('DESC'), 'DESC');
1027
+ assert.throws(() => table(mockDb(), 'posts').orderBy('title', 'sideways'), /Order direction must be/);
1028
+ });
1029
+
1030
+ test('sum() answers 0 on an empty set (Laravel: `$result ?: 0`)', async () => {
1031
+ // Laravel's sum() (Query/Builder.php:4099-4104): `return $result ?: 0;` - an
1032
+ // empty set (or an all-NULL column) answers 0, not null (the JSON body
1033
+ // differed: null vs 0).
1034
+ const db = mockDb({ first: () => ({ aggregate: null }) });
1035
+ assert.equal(await table(db, 'posts').sum('views'), 0);
1036
+ const db2 = mockDb({ first: () => ({ aggregate: '7' }) });
1037
+ assert.equal(await table(db2, 'posts').sum('views'), 7);
1038
+ });
1039
+
1040
+ test('a Date is bound with the grammar date format (Laravel: Y-m-d H:i:s)', async () => {
1041
+ // Laravel's Connection::prepareBindings(): a DateTimeInterface is bound with
1042
+ // the grammar's date format - an ISO string never matches the stored TEXT
1043
+ // column.
1044
+ const calls = [];
1045
+ const stmt = {
1046
+ bind(...p) { calls.push({ sql: this.sql, params: p }); return this; },
1047
+ run: async () => ({ success: true, meta: { changes: 1 } }),
1048
+ all: async () => ({ results: [] }),
1049
+ first: async () => null,
1050
+ };
1051
+ const db = { prepare(sql) { stmt.sql = sql; return stmt; } };
1052
+ await table(db, 'posts').where('created_at', new Date('2024-01-02T03:04:05Z')).get();
1053
+ assert.deepEqual(calls[0].params, ['2024-01-02 03:04:05']);
1054
+ });
1055
+
1056
+ test('find() with an array of ids returns the rows (Laravel: findMany)', async () => {
1057
+ // Laravel's find() (Eloquent/Builder.php:607-616): an array of ids returns a
1058
+ // COLLECTION of the found models; the edge bound the array as ONE value (a
1059
+ // query that matched nothing).
1060
+ const db = mockDb({ all: () => ({ results: [{ id: 1 }, { id: 3 }] }) });
1061
+ class M extends Model {}
1062
+ M.table = 'posts';
1063
+ M.primaryKey = 'id';
1064
+ const found = await M.find(db, [1, 3]);
1065
+ assert.equal(Array.isArray(found), true, 'an array of ids returns a list');
1066
+ assert.deepEqual(found.map((m) => m.get('id')), [1, 3]);
1067
+ // findOrFail with an array names the missing ids.
1068
+ await assert.rejects(() => M.findOrFail(db, [1, 9]), /not found: 9/);
1069
+ });