also looking at this
fix(orm): save() inserts or updates by whether the row exists
#1161
3 files
+312
-16
| @@ -1062,10 +1062,21 @@ class ModelInstance< | ||
| 1062 | 1062 | private _forced: Set<string> = new Set() |
| 1063 | 1063 | private _definition: TDef |
| 1064 | 1064 | private _hasSaved = false |
| 1065 | /** | |
| 1066 | * Whether this instance is a row in the table: true when it was read from | |
| 1067 | * the database or has been inserted, false for one built by `create()`, | |
| 1068 | * `make()` or `replicate()` that has not been saved yet. save() branches on | |
| 1069 | * this. It used to branch on whether the primary key was truthy, which sent | |
| 1070 | * a caller-supplied key (`create({ id: 5 })`, or any string key) down the | |
| 1071 | * UPDATE path, so nothing was inserted, and sent a loaded row whose key was | |
| 1072 | * `0` down the INSERT path, so it was duplicated. | |
| 1073 | */ | |
| 1074 | private _exists: boolean | |
| 1065 | 1075 | private _relations: Record<string, ModelInstance<any, any>[] | ModelInstance<any, any> | null> = {} |
| 1066 | 1076 | |
| 1067 | constructor(definition: TDef, attributes: Partial<ModelAttributes<TDef>> = {}) { | |
| 1077 | constructor(definition: TDef, attributes: Partial<ModelAttributes<TDef>>, exists: boolean) { | |
| 1068 | 1078 | this._definition = definition |
| 1079 | this._exists = exists | |
| 1069 | 1080 | // The spread already gives us a private copy, so the normalizer may reuse |
| 1070 | 1081 | // it instead of building a second object per hydrated row. |
| 1071 | 1082 | this._attributes = normalizeAttributeKeysInPlace({ ...attributes }) |
| @@ -1306,13 +1317,17 @@ class ModelInstance< | ||
| 1306 | 1317 | } |
| 1307 | 1318 | } |
| 1308 | 1319 | |
| 1309 | if (this._attributes[pk]) { | |
| 1320 | if (this._exists) { | |
| 1310 | 1321 | // Update |
| 1311 | 1322 | await hooks?.beforeUpdate?.(this as unknown as ModelHookInstance, this.getChanges()) |
| 1312 | 1323 | |
| 1313 | 1324 | const changes = this.getChanges() |
| 1314 | 1325 | const changeKeys = Object.keys(changes) |
| 1315 | 1326 | if (changeKeys.length > 0) { |
| 1327 | // A row read without its key (`select('title')`) cannot be addressed. | |
| 1328 | // This used to take the INSERT branch and write a copy of the row. | |
| 1329 | if (this._attributes[pk] == null) | |
| 1330 | throw new Error(`Cannot save a model without a primary key: '${pk}' was not selected`) | |
| 1316 | 1331 | const sets = changeKeys.map(k => `${k} = ?`).join(', ') |
| 1317 | 1332 | const values = [...Object.values(changes), this._attributes[pk]] |
| 1318 | 1333 | |
| @@ -1375,6 +1390,15 @@ class ModelInstance< | ||
| 1375 | 1390 | } |
| 1376 | 1391 | } |
| 1377 | 1392 | |
| 1393 | // A key the caller supplied is written. Neither loop above picks it up | |
| 1394 | // unless the model declares it as an attribute, and a string key has no | |
| 1395 | // database default to fall back on. | |
| 1396 | const suppliedKey = this._attributes[pk] | |
| 1397 | const keyAttr = attrs[pk] | |
| 1398 | const keyGuarded = keyAttr?.guarded && !this._forced.has(pk) | |
| 1399 | if (suppliedKey != null && !(pk in data) && !keyGuarded) | |
| 1400 | data[pk] = suppliedKey | |
| 1401 | ||
| 1378 | 1402 | if (timestampsEnabled(this._definition)) { |
| 1379 | 1403 | const now = formatNow() |
| 1380 | 1404 | // An explicitly supplied `created_at` wins. |
| @@ -1416,8 +1440,12 @@ class ModelInstance< | ||
| 1416 | 1440 | for (const [key, value] of Object.entries(data)) { |
| 1417 | 1441 | this._attributes[key] = value |
| 1418 | 1442 | } |
| 1419 | if (result.lastInsertId != null) | |
| 1443 | // Only a generated key is read back. For a supplied one the driver's | |
| 1444 | // insert id is the rowid (SQLite) or 0 (MySQL on a non-AUTO_INCREMENT | |
| 1445 | // key), not the key. | |
| 1446 | if (!(pk in data) && result.lastInsertId != null) | |
| 1420 | 1447 | this._attributes[pk] = result.lastInsertId |
| 1448 | this._exists = true | |
| 1421 | 1449 | |
| 1422 | 1450 | await hooks?.afterCreate?.(this as unknown as ModelHookInstance) |
| 1423 | 1451 | } |
| @@ -1446,7 +1474,7 @@ class ModelInstance< | ||
| 1446 | 1474 | if (id == null) return null |
| 1447 | 1475 | const row = await exec.get(`SELECT * FROM ${this._definition.table} WHERE ${pk} = ?`, [id]) |
| 1448 | 1476 | if (!row) return null |
| 1449 | return new ModelInstance(this._definition, row as Partial<ModelAttributes<TDef>>) | |
| 1477 | return new ModelInstance(this._definition, row as Partial<ModelAttributes<TDef>>, true) | |
| 1450 | 1478 | } |
| 1451 | 1479 | |
| 1452 | 1480 | async delete(): Promise<boolean> { |
| @@ -1569,7 +1597,7 @@ class ModelInstance< | ||
| 1569 | 1597 | delete attrs.uuid |
| 1570 | 1598 | delete attrs.created_at |
| 1571 | 1599 | delete attrs.updated_at |
| 1572 | return new ModelInstance<TDef, TSelected>(this._definition, attrs as any) | |
| 1600 | return new ModelInstance<TDef, TSelected>(this._definition, attrs as any, false) | |
| 1573 | 1601 | } |
| 1574 | 1602 | |
| 1575 | 1603 | toArray(): Record<string, unknown> { |
| @@ -2280,7 +2308,7 @@ export class BelongsToManyRelationBuilder<TRel extends ModelDefinition> { | ||
| 2280 | 2308 | else if (k !== fkParent && k !== fkRelated) |
| 2281 | 2309 | pivotExtras[k] = v |
| 2282 | 2310 | } |
| 2283 | const inst = new ModelInstance(this._relatedDef, relatedRow as any) | |
| 2311 | const inst = new ModelInstance(this._relatedDef, relatedRow as any, true) | |
| 2284 | 2312 | ;(inst as any).pivot = pivotExtras |
| 2285 | 2313 | return inst |
| 2286 | 2314 | }) |
| @@ -3036,7 +3064,7 @@ class ModelQueryBuilder< | ||
| 3036 | 3064 | } |
| 3037 | 3065 | for (const instance of instances) { |
| 3038 | 3066 | const related = grouped.get(instance.get(pk as any)) || [] |
| 3039 | instance.setRelation(relationName, related.map(r => new ModelInstance(relDef as any, r as any))) | |
| 3067 | instance.setRelation(relationName, related.map(r => new ModelInstance(relDef as any, r as any, true))) | |
| 3040 | 3068 | } |
| 3041 | 3069 | } |
| 3042 | 3070 | else { |
| @@ -3051,7 +3079,7 @@ class ModelQueryBuilder< | ||
| 3051 | 3079 | } |
| 3052 | 3080 | for (const instance of instances) { |
| 3053 | 3081 | const row = byFk.get(instance.get(pk as any)) |
| 3054 | instance.setRelation(relationName, row ? new ModelInstance(relDef as any, row as any) : null) | |
| 3082 | instance.setRelation(relationName, row ? new ModelInstance(relDef as any, row as any, true) : null) | |
| 3055 | 3083 | } |
| 3056 | 3084 | } |
| 3057 | 3085 | } |
| @@ -3083,7 +3111,7 @@ class ModelQueryBuilder< | ||
| 3083 | 3111 | for (const instance of instances) { |
| 3084 | 3112 | const fkVal = (instance as any)._attributes[rel.foreignKey] |
| 3085 | 3113 | const row = byPk.get(fkVal) |
| 3086 | instance.setRelation(relationName, row ? new ModelInstance(relDef as any, row as any) : null) | |
| 3114 | instance.setRelation(relationName, row ? new ModelInstance(relDef as any, row as any, true) : null) | |
| 3087 | 3115 | } |
| 3088 | 3116 | } |
| 3089 | 3117 | |
| @@ -3144,7 +3172,7 @@ class ModelQueryBuilder< | ||
| 3144 | 3172 | .map((p) => { |
| 3145 | 3173 | const relRow = relatedByPk.get(p[rel.pivotFkRelated!]) |
| 3146 | 3174 | if (!relRow) return null |
| 3147 | const inst = new ModelInstance(relDef as any, relRow as any) | |
| 3175 | const inst = new ModelInstance(relDef as any, relRow as any, true) | |
| 3148 | 3176 | // Attach pivot extras under instance.pivot |
| 3149 | 3177 | const extras: Record<string, unknown> = {} |
| 3150 | 3178 | for (const [k, v] of Object.entries(p)) { |
| @@ -3197,7 +3225,7 @@ class ModelQueryBuilder< | ||
| 3197 | 3225 | const parentVal = throughToParent.get(row[rel.targetForeignKey!]) |
| 3198 | 3226 | if (parentVal == null) continue |
| 3199 | 3227 | if (!byParent.has(parentVal)) byParent.set(parentVal, []) |
| 3200 | byParent.get(parentVal)!.push(new ModelInstance(relDef as any, row as any)) | |
| 3228 | byParent.get(parentVal)!.push(new ModelInstance(relDef as any, row as any, true)) | |
| 3201 | 3229 | } |
| 3202 | 3230 | |
| 3203 | 3231 | for (const instance of instances) { |
| @@ -3211,7 +3239,7 @@ class ModelQueryBuilder< | ||
| 3211 | 3239 | const exec = getExecutor() |
| 3212 | 3240 | const { sql, params } = this.buildQuery() |
| 3213 | 3241 | const rows = await exec.all(sql, params) |
| 3214 | const instances = rows.map(row => new ModelInstance<TDef, TSelected>(this._definition, row as any)) | |
| 3242 | const instances = rows.map(row => new ModelInstance<TDef, TSelected>(this._definition, row as any, true)) | |
| 3215 | 3243 | |
| 3216 | 3244 | // Eager load relations |
| 3217 | 3245 | if (this._withRelations.length > 0) { |
| @@ -3710,7 +3738,7 @@ function createModelInternal<const TDef extends ModelDefinition>(definition: TDe | ||
| 3710 | 3738 | // Exclude soft-deleted rows by default (use withTrashed()/query() to override). |
| 3711 | 3739 | const sd = softDeletesEnabled(definition) ? ` AND ${SOFT_DELETE_COLUMN} IS NULL` : '' |
| 3712 | 3740 | const row = await exec.get(`SELECT * FROM ${definition.table} WHERE ${pk} = ?${sd}`, [id]) |
| 3713 | return row ? new ModelInstance<TDef>(definition, row as any) as ModelRecord<TDef> : undefined | |
| 3741 | return row ? new ModelInstance<TDef>(definition, row as any, true) as ModelRecord<TDef> : undefined | |
| 3714 | 3742 | }, |
| 3715 | 3743 | |
| 3716 | 3744 | async findOrFail(id: number | string): Promise<ModelRecord<TDef>> { |
| @@ -3724,7 +3752,7 @@ function createModelInternal<const TDef extends ModelDefinition>(definition: TDe | ||
| 3724 | 3752 | const pk = definition.primaryKey || 'id' |
| 3725 | 3753 | const sd = softDeletesEnabled(definition) ? ` AND ${SOFT_DELETE_COLUMN} IS NULL` : '' |
| 3726 | 3754 | const rows = await exec.all(`SELECT * FROM ${definition.table} WHERE ${pk} IN (${ids.map(() => '?').join(', ')})${sd}`, ids) |
| 3727 | return rows.map(row => new ModelInstance<TDef>(definition, row as any) as ModelRecord<TDef>) | |
| 3755 | return rows.map(row => new ModelInstance<TDef>(definition, row as any, true) as ModelRecord<TDef>) | |
| 3728 | 3756 | }, |
| 3729 | 3757 | |
| 3730 | 3758 | all: () => new ModelQueryBuilder<TDef>(definition).get(), |
| @@ -3745,7 +3773,7 @@ function createModelInternal<const TDef extends ModelDefinition>(definition: TDe | ||
| 3745 | 3773 | }, |
| 3746 | 3774 | |
| 3747 | 3775 | async create(data: FillableAttributes<TDef>): Promise<ModelRecord<TDef>> { |
| 3748 | const instance = new ModelInstance<TDef>(definition, data as any) | |
| 3776 | const instance = new ModelInstance<TDef>(definition, data as any, false) | |
| 3749 | 3777 | await instance.save() |
| 3750 | 3778 | return instance as ModelRecord<TDef> |
| 3751 | 3779 | }, |
| @@ -3843,7 +3871,7 @@ function createModelInternal<const TDef extends ModelDefinition>(definition: TDe | ||
| 3843 | 3871 | getTable: () => definition.table, |
| 3844 | 3872 | |
| 3845 | 3873 | make(data: Partial<Attrs> = {}): ModelInstance<TDef> { |
| 3846 | return new ModelInstance<TDef>(definition, data as any) | |
| 3874 | return new ModelInstance<TDef>(definition, data as any, false) | |
| 3847 | 3875 | }, |
| 3848 | 3876 | |
| 3849 | 3877 | latest: (column: Cols = 'created_at' as Cols) => new ModelQueryBuilder<TDef>(definition).orderByDesc(column), |
| @@ -0,0 +1,103 @@ | ||
| 1 | /** | |
| 2 | * `save()` INSERT vs UPDATE against a live Postgres — see | |
| 3 | * orm.save-primary-key.test.ts for the bug. Postgres inserts through | |
| 4 | * `RETURNING <pk>`, a different path from SQLite's rowid, so the supplied-key | |
| 5 | * and string-key cases are executed here too. | |
| 6 | * | |
| 7 | * Runs in a subprocess: an earlier configureOrm() in the same process pins the | |
| 8 | * model layer to sqlite and would mask the network-driver path. | |
| 9 | */ | |
| 10 | ||
| 11 | import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 12 | import { tmpdir } from 'node:os' | |
| 13 | import { join, resolve } from 'node:path' | |
| 14 | import process from 'node:process' | |
| 15 | import { describe, expect, it } from 'bun:test' | |
| 16 | import { PG_URL, probePostgres } from './pg' | |
| 17 | ||
| 18 | const pgAvailable = await probePostgres() | |
| 19 | ||
| 20 | describe.skipIf(!pgAvailable)('save() primary key handling against live Postgres', () => { | |
| 21 | it('inserts supplied and string keys, and updates a row keyed 0', () => { | |
| 22 | const srcEntry = resolve(import.meta.dir, '../src/index.ts') | |
| 23 | const dir = mkdtempSync(join(tmpdir(), 'qb-savepk-')) | |
| 24 | const scriptPath = join(dir, 'probe.ts') | |
| 25 | ||
| 26 | writeFileSync(scriptPath, ` | |
| 27 | import { SQL } from 'bun' | |
| 28 | import { setConfig, resetConnection, defineModel, clearModelRegistry } from ${JSON.stringify(srcEntry)} | |
| 29 | ||
| 30 | const URL = ${JSON.stringify(PG_URL)} | |
| 31 | const failures = [] | |
| 32 | const check = (label, got, want) => { | |
| 33 | const g = JSON.stringify(got), w = JSON.stringify(want) | |
| 34 | if (g !== w) failures.push(label + ': got ' + g + ', want ' + w) | |
| 35 | } | |
| 36 | const attempt = async (label, fn) => { | |
| 37 | try { return await fn() } | |
| 38 | catch (e) { failures.push(label + ': threw ' + (e && e.message)); return undefined } | |
| 39 | } | |
| 40 | ||
| 41 | const raw = new SQL(URL) | |
| 42 | await raw.unsafe('DROP TABLE IF EXISTS _qb_savepk_posts') | |
| 43 | await raw.unsafe('DROP TABLE IF EXISTS _qb_savepk_countries') | |
| 44 | await raw.unsafe('CREATE TABLE _qb_savepk_posts (id serial primary key, title text, created_at timestamptz, updated_at timestamptz)') | |
| 45 | await raw.unsafe('CREATE TABLE _qb_savepk_countries (code text primary key, name text)') | |
| 46 | const posts = async () => (await raw.unsafe('SELECT id, title FROM _qb_savepk_posts ORDER BY id')).map(r => [Number(r.id), r.title]) | |
| 47 | const countries = async () => (await raw.unsafe('SELECT code, name FROM _qb_savepk_countries ORDER BY code')).map(r => [r.code, r.name]) | |
| 48 | ||
| 49 | setConfig({ dialect: 'postgres', database: { url: URL } }) | |
| 50 | resetConnection() | |
| 51 | clearModelRegistry() | |
| 52 | const Post = defineModel({ name: 'SavepkPost', table: '_qb_savepk_posts', primaryKey: 'id', | |
| 53 | attributes: { title: { type: 'string', fillable: true } } }) | |
| 54 | const Country = defineModel({ name: 'SavepkCountry', table: '_qb_savepk_countries', primaryKey: 'code', | |
| 55 | traits: { useTimestamps: false }, attributes: { name: { type: 'string', fillable: true } } }) | |
| 56 | ||
| 57 | const five = await attempt('create with id', () => Post.create({ id: 5, title: 'five' })) | |
| 58 | check('create with id: key', five && Number(five.get('id')), 5) | |
| 59 | check('create with id: rows', await posts(), [[5, 'five']]) | |
| 60 | ||
| 61 | const auto = await attempt('create generated', () => Post.create({ title: 'auto' })) | |
| 62 | check('create generated: key read back', auto && Number(auto.get('id')) > 0, true) | |
| 63 | ||
| 64 | const ph = await attempt('string key create', () => Country.create({ code: 'PH', name: 'Philippines' })) | |
| 65 | check('string key: key', ph && ph.get('code'), 'PH') | |
| 66 | check('string key: rows', await countries(), [['PH', 'Philippines']]) | |
| 67 | if (ph) { | |
| 68 | ph.set('name', 'Republic of the Philippines') | |
| 69 | await attempt('string key update', () => ph.save()) | |
| 70 | check('string key: updated in place', await countries(), [['PH', 'Republic of the Philippines']]) | |
| 71 | } | |
| 72 | ||
| 73 | await raw.unsafe("INSERT INTO _qb_savepk_posts (id, title) VALUES (0, 'zero')") | |
| 74 | const zero = await attempt('find 0', () => Post.find(0)) | |
| 75 | if (zero) { | |
| 76 | zero.set('title', 'zero, edited') | |
| 77 | await attempt('save 0', () => zero.save()) | |
| 78 | } | |
| 79 | const after = await posts() | |
| 80 | check('key 0: updated, not copied', after.filter(([, t]) => String(t).startsWith('zero')), [[0, 'zero, edited']]) | |
| 81 | ||
| 82 | await raw.unsafe('DROP TABLE IF EXISTS _qb_savepk_posts') | |
| 83 | await raw.unsafe('DROP TABLE IF EXISTS _qb_savepk_countries') | |
| 84 | await raw.end() | |
| 85 | ||
| 86 | if (failures.length) { | |
| 87 | console.error('FAILURES:\\n' + failures.join('\\n')) | |
| 88 | process.exit(1) | |
| 89 | } | |
| 90 | console.log('OK') | |
| 91 | process.exit(0) | |
| 92 | `) | |
| 93 | ||
| 94 | const proc = Bun.spawnSync({ cmd: ['bun', scriptPath], stdout: 'pipe', stderr: 'pipe', cwd: process.cwd(), env: { ...process.env } }) | |
| 95 | const dec = new TextDecoder() | |
| 96 | const out = dec.decode(proc.stdout).trim() | |
| 97 | const err = dec.decode(proc.stderr).trim() | |
| 98 | rmSync(dir, { recursive: true, force: true }) | |
| 99 | ||
| 100 | expect(proc.exitCode, `save() primary key pg probe failed.\nstdout: ${out}\nstderr: ${err}`).toBe(0) | |
| 101 | expect(out).toContain('OK') | |
| 102 | }, 30_000) | |
| 103 | }) | |
| @@ -0,0 +1,165 @@ | ||
| 1 | /** | |
| 2 | * `save()` decides INSERT vs UPDATE by whether the row exists, not by whether | |
| 3 | * its primary key is truthy. | |
| 4 | * | |
| 5 | * It used to test `if (this._attributes[pk])`. That sent three cases the wrong | |
| 6 | * way, all without an error: | |
| 7 | * | |
| 8 | * - `create({ id: 5, … })` took the UPDATE branch, matched no row, and returned | |
| 9 | * an instance with id 5 for a row that was never written. | |
| 10 | * - A model keyed by a string (`code`, a slug, a UUID) could not create rows at | |
| 11 | * all, for the same reason. | |
| 12 | * - A loaded row whose key was `0` took the INSERT branch, so editing it wrote | |
| 13 | * a copy. | |
| 14 | */ | |
| 15 | ||
| 16 | import { Database } from 'bun:sqlite' | |
| 17 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 18 | import { clearModelRegistry, configureOrm, createModel } from '../src' | |
| 19 | ||
| 20 | let db: Database | |
| 21 | let Post: any | |
| 22 | let Country: any | |
| 23 | const hooks: string[] = [] | |
| 24 | ||
| 25 | beforeEach(() => { | |
| 26 | clearModelRegistry() | |
| 27 | hooks.length = 0 | |
| 28 | db = new Database(':memory:', { create: true }) | |
| 29 | configureOrm({ database: db }) | |
| 30 | db.run('CREATE TABLE sp_posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, created_at TEXT, updated_at TEXT)') | |
| 31 | db.run('CREATE TABLE sp_countries (code TEXT PRIMARY KEY, name TEXT)') | |
| 32 | ||
| 33 | Post = createModel({ | |
| 34 | name: 'SpPost', | |
| 35 | table: 'sp_posts', | |
| 36 | primaryKey: 'id', | |
| 37 | attributes: { title: { type: 'string', fillable: true } }, | |
| 38 | } as any) | |
| 39 | Country = createModel({ | |
| 40 | name: 'SpCountry', | |
| 41 | table: 'sp_countries', | |
| 42 | primaryKey: 'code', | |
| 43 | traits: { useTimestamps: false }, | |
| 44 | attributes: { name: { type: 'string', fillable: true } }, | |
| 45 | hooks: { | |
| 46 | beforeCreate: () => { hooks.push('beforeCreate') }, | |
| 47 | afterCreate: () => { hooks.push('afterCreate') }, | |
| 48 | beforeUpdate: () => { hooks.push('beforeUpdate') }, | |
| 49 | afterUpdate: () => { hooks.push('afterUpdate') }, | |
| 50 | }, | |
| 51 | } as any) | |
| 52 | }) | |
| 53 | ||
| 54 | afterEach(() => { | |
| 55 | clearModelRegistry() | |
| 56 | db.close() | |
| 57 | }) | |
| 58 | ||
| 59 | const posts = (): any[] => db.query('SELECT id, title FROM sp_posts ORDER BY id').all() as any[] | |
| 60 | const countries = (): any[] => db.query('SELECT code, name FROM sp_countries ORDER BY code').all() as any[] | |
| 61 | ||
| 62 | describe('create() with a key the caller supplies', () => { | |
| 63 | it('writes the row under that key', async () => { | |
| 64 | // Was an UPDATE that matched nothing: the instance said id 5, the table was empty. | |
| 65 | const post = await Post.create({ id: 5, title: 'five' }) | |
| 66 | expect(post.get('id')).toBe(5) | |
| 67 | expect(posts()).toEqual([{ id: 5, title: 'five' }]) | |
| 68 | }) | |
| 69 | ||
| 70 | it('creates rows on a model keyed by a string', async () => { | |
| 71 | const ph = await Country.create({ code: 'PH', name: 'Philippines' }) | |
| 72 | // The key stays the string, not the SQLite rowid the driver reports. | |
| 73 | expect(ph.get('code')).toBe('PH') | |
| 74 | expect(countries()).toEqual([{ code: 'PH', name: 'Philippines' }]) | |
| 75 | expect(hooks).toEqual(['beforeCreate', 'afterCreate']) | |
| 76 | }) | |
| 77 | ||
| 78 | it('a later save() on the created instance updates it', async () => { | |
| 79 | const ph = await Country.create({ code: 'PH', name: 'Philippines' }) | |
| 80 | hooks.length = 0 | |
| 81 | ph.set('name', 'Republic of the Philippines') | |
| 82 | await ph.save() | |
| 83 | expect(countries()).toEqual([{ code: 'PH', name: 'Republic of the Philippines' }]) | |
| 84 | expect(hooks).toEqual(['beforeUpdate', 'afterUpdate']) | |
| 85 | }) | |
| 86 | ||
| 87 | it('still reads back a generated key when none is supplied', async () => { | |
| 88 | const a = await Post.create({ title: 'a' }) | |
| 89 | const b = await Post.create({ title: 'b' }) | |
| 90 | expect([a.get('id'), b.get('id')]).toEqual([1, 2]) | |
| 91 | b.set('title', 'b2') | |
| 92 | await b.save() | |
| 93 | expect(posts()).toEqual([{ id: 1, title: 'a' }, { id: 2, title: 'b2' }]) | |
| 94 | }) | |
| 95 | ||
| 96 | it('treats a null key as not supplied', async () => { | |
| 97 | const post = await Post.create({ id: null, title: 'auto' }) | |
| 98 | expect(post.get('id')).toBe(1) | |
| 99 | expect(posts()).toEqual([{ id: 1, title: 'auto' }]) | |
| 100 | }) | |
| 101 | ||
| 102 | it('leaves a guarded key to the database', async () => { | |
| 103 | clearModelRegistry() | |
| 104 | const Guarded = createModel({ | |
| 105 | name: 'SpGuarded', | |
| 106 | table: 'sp_posts', | |
| 107 | primaryKey: 'id', | |
| 108 | attributes: { id: { type: 'number', guarded: true }, title: { type: 'string', fillable: true } }, | |
| 109 | } as any) as any | |
| 110 | const post = await Guarded.create({ id: 99, title: 'g' }) | |
| 111 | expect(post.get('id')).toBe(1) | |
| 112 | expect(posts()).toEqual([{ id: 1, title: 'g' }]) | |
| 113 | }) | |
| 114 | }) | |
| 115 | ||
| 116 | describe('save() on a loaded row', () => { | |
| 117 | it('updates a row whose key is 0 instead of copying it', async () => { | |
| 118 | db.run(`INSERT INTO sp_posts (id, title) VALUES (0, 'zero')`) | |
| 119 | const post = await Post.find(0) | |
| 120 | post.set('title', 'zero, edited') | |
| 121 | await post.save() | |
| 122 | expect(posts()).toEqual([{ id: 0, title: 'zero, edited' }]) | |
| 123 | }) | |
| 124 | ||
| 125 | it('updates a string-keyed row read by a query', async () => { | |
| 126 | db.run(`INSERT INTO sp_countries (code, name) VALUES ('JP', 'Japan')`) | |
| 127 | const jp = await Country.query().where('code', 'JP').first() | |
| 128 | jp.set('name', 'Nippon') | |
| 129 | await jp.save() | |
| 130 | expect(countries()).toEqual([{ code: 'JP', name: 'Nippon' }]) | |
| 131 | }) | |
| 132 | ||
| 133 | it('refuses to save changes to a row read without its key', async () => { | |
| 134 | // Took the INSERT branch and wrote a copy of the row. | |
| 135 | await Post.create({ title: 'a' }) | |
| 136 | const partial = await Post.query().select('title').first() | |
| 137 | partial.set('title', 'b') | |
| 138 | await expect(partial.save()).rejects.toThrow(`Cannot save a model without a primary key: 'id' was not selected`) | |
| 139 | expect(posts()).toEqual([{ id: 1, title: 'a' }]) | |
| 140 | }) | |
| 141 | ||
| 142 | it('a save with no changes on such a row is still a no-op', async () => { | |
| 143 | await Post.create({ title: 'a' }) | |
| 144 | const partial = await Post.query().select('title').first() | |
| 145 | await partial.save() | |
| 146 | expect(posts()).toEqual([{ id: 1, title: 'a' }]) | |
| 147 | }) | |
| 148 | }) | |
| 149 | ||
| 150 | describe('instances that are not rows yet', () => { | |
| 151 | it('make() then save() inserts, with or without a key', async () => { | |
| 152 | await Post.make({ title: 'made' }).save() | |
| 153 | await Country.make({ code: 'FR', name: 'France' }).save() | |
| 154 | expect(posts()).toEqual([{ id: 1, title: 'made' }]) | |
| 155 | expect(countries()).toEqual([{ code: 'FR', name: 'France' }]) | |
| 156 | }) | |
| 157 | ||
| 158 | it('replicate() then save() inserts a new row', async () => { | |
| 159 | const original = await Post.create({ title: 'orig' }) | |
| 160 | const copy = original.replicate() | |
| 161 | copy.set('title', 'copy') | |
| 162 | await copy.save() | |
| 163 | expect(posts()).toEqual([{ id: 1, title: 'orig' }, { id: 2, title: 'copy' }]) | |
| 164 | }) | |
| 165 | }) | |