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
9 days ago

Problem

ModelInstance.save() picked INSERT or UPDATE with if (this._attributes[pk]). Three cases went the wrong way, and none of them raised an error:

CallBeforeAfter
Post.create({ id: 5, title })UPDATE matched nothing. Returned an instance with id 5, no row writtenrow 5 inserted
Country.create({ code: 'PH', name }) (string key)same: string-keyed models could not create rows at allrow inserted, key stays 'PH'
(await Post.find(0)).set(...).save()INSERT: wrote a copy of row 0row 0 updated
select('title') row, edited, save()INSERT: wrote a copythrows Cannot save a model without a primary key

All four reproduce on SQLite and on live Postgres.

Fix

  • Instances track whether they exist as a row in the table. Every hydration site (find, findMany, get/first, eager loads, belongsToMany, fresh) passes true. create(), make() and replicate() pass false, and a successful insert flips it to true. The flag is a required constructor argument, so the compiler makes each call site choose.
  • The INSERT writes a caller-supplied key. Neither existing loop picked it up unless the model declared the key as an attribute. A guarded key attribute is still left to the database.
  • After an insert, the driver's insert id is read back only for a generated key. For a supplied key it's the wrong value: the SQLite rowid, or 0 on MySQL for a key without AUTO_INCREMENT.
  • A save that has changes but no key to address them now throws instead of inserting a copy. A save with no changes stays a no-op.

ModelInstance is exported only as a type, so the constructor change isn't public API.

Tests

  • test/orm.save-primary-key.test.ts: 12 tests. 8 fail on main.
  • test/orm.save-primary-key.pg.test.ts: the supplied-key, string-key and key-0 cases against live Postgres, which inserts via RETURNING. It fails on main with the same symptoms, including the duplicated 0 row.

Full suite: 2,286 pass / 0 fail / 10 skip. tsc clean. Lint adds no new warnings (334 on main as well).

Not covered: MySQL. There's no local server to execute against, so the lastInsertId guard there is reasoned from DriverExecutor.insert, not run.

🤖 Generated with Claude Code

3 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.