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.test.tsadded+165-0
Changes to packages/bun-query-builder/test/orm.save-primary-key.test.ts
@@ -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
16import { Database } from 'bun:sqlite'
17import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
18import { clearModelRegistry, configureOrm, createModel } from '../src'
19
20let db: Database
21let Post: any
22let Country: any
23const hooks: string[] = []
24
25beforeEach(() => {
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
54afterEach(() => {
55 clearModelRegistry()
56 db.close()
57})
58
59const posts = (): any[] => db.query('SELECT id, title FROM sp_posts ORDER BY id').all() as any[]
60const countries = (): any[] => db.query('SELECT code, name FROM sp_countries ORDER BY code').all() as any[]
61
62describe('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
116describe('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
150describe('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})