ReviewOS

also looking at this

stacks/bun-query-builder

fix(orm): save() inserts or updates by whether the row exists

#1161
Merged glennmichael123 wants to merge fix/save-primary-key-detection into main
3 files +312 -16
packages/bun-query-builder/test/orm.save-primary-key.pg.test.tsadded+103-0
Changes to packages/bun-query-builder/test/orm.save-primary-key.pg.test.ts
@@ -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
11import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
12import { tmpdir } from 'node:os'
13import { join, resolve } from 'node:path'
14import process from 'node:process'
15import { describe, expect, it } from 'bun:test'
16import { PG_URL, probePostgres } from './pg'
17
18const pgAvailable = await probePostgres()
19
20describe.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, `
27import { SQL } from 'bun'
28import { setConfig, resetConnection, defineModel, clearModelRegistry } from ${JSON.stringify(srcEntry)}
29
30const URL = ${JSON.stringify(PG_URL)}
31const failures = []
32const 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}
36const attempt = async (label, fn) => {
37 try { return await fn() }
38 catch (e) { failures.push(label + ': threw ' + (e && e.message)); return undefined }
39}
40
41const raw = new SQL(URL)
42await raw.unsafe('DROP TABLE IF EXISTS _qb_savepk_posts')
43await raw.unsafe('DROP TABLE IF EXISTS _qb_savepk_countries')
44await raw.unsafe('CREATE TABLE _qb_savepk_posts (id serial primary key, title text, created_at timestamptz, updated_at timestamptz)')
45await raw.unsafe('CREATE TABLE _qb_savepk_countries (code text primary key, name text)')
46const posts = async () => (await raw.unsafe('SELECT id, title FROM _qb_savepk_posts ORDER BY id')).map(r => [Number(r.id), r.title])
47const countries = async () => (await raw.unsafe('SELECT code, name FROM _qb_savepk_countries ORDER BY code')).map(r => [r.code, r.name])
48
49setConfig({ dialect: 'postgres', database: { url: URL } })
50resetConnection()
51clearModelRegistry()
52const Post = defineModel({ name: 'SavepkPost', table: '_qb_savepk_posts', primaryKey: 'id',
53 attributes: { title: { type: 'string', fillable: true } } })
54const Country = defineModel({ name: 'SavepkCountry', table: '_qb_savepk_countries', primaryKey: 'code',
55 traits: { useTimestamps: false }, attributes: { name: { type: 'string', fillable: true } } })
56
57const five = await attempt('create with id', () => Post.create({ id: 5, title: 'five' }))
58check('create with id: key', five && Number(five.get('id')), 5)
59check('create with id: rows', await posts(), [[5, 'five']])
60
61const auto = await attempt('create generated', () => Post.create({ title: 'auto' }))
62check('create generated: key read back', auto && Number(auto.get('id')) > 0, true)
63
64const ph = await attempt('string key create', () => Country.create({ code: 'PH', name: 'Philippines' }))
65check('string key: key', ph && ph.get('code'), 'PH')
66check('string key: rows', await countries(), [['PH', 'Philippines']])
67if (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
73await raw.unsafe("INSERT INTO _qb_savepk_posts (id, title) VALUES (0, 'zero')")
74const zero = await attempt('find 0', () => Post.find(0))
75if (zero) {
76 zero.set('title', 'zero, edited')
77 await attempt('save 0', () => zero.save())
78}
79const after = await posts()
80check('key 0: updated, not copied', after.filter(([, t]) => String(t).startsWith('zero')), [[0, 'zero, edited']])
81
82await raw.unsafe('DROP TABLE IF EXISTS _qb_savepk_posts')
83await raw.unsafe('DROP TABLE IF EXISTS _qb_savepk_countries')
84await raw.end()
85
86if (failures.length) {
87 console.error('FAILURES:\\n' + failures.join('\\n'))
88 process.exit(1)
89}
90console.log('OK')
91process.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})