also looking at this
fix(orm): save() inserts or updates by whether the row exists
#1161
3 files
+312
-16
| @@ -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 | }) | |