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/src/orm.tsmodified+44-16
Changes to packages/bun-query-builder/src/orm.ts
@@ -1062,10 +1062,21 @@ class ModelInstance<
10621062 private _forced: Set<string> = new Set()
10631063 private _definition: TDef
10641064 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
10651075 private _relations: Record<string, ModelInstance<any, any>[] | ModelInstance<any, any> | null> = {}
10661076
1067 constructor(definition: TDef, attributes: Partial<ModelAttributes<TDef>> = {}) {
1077 constructor(definition: TDef, attributes: Partial<ModelAttributes<TDef>>, exists: boolean) {
10681078 this._definition = definition
1079 this._exists = exists
10691080 // The spread already gives us a private copy, so the normalizer may reuse
10701081 // it instead of building a second object per hydrated row.
10711082 this._attributes = normalizeAttributeKeysInPlace({ ...attributes })
@@ -1306,13 +1317,17 @@ class ModelInstance<
13061317 }
13071318 }
13081319
1309 if (this._attributes[pk]) {
1320 if (this._exists) {
13101321 // Update
13111322 await hooks?.beforeUpdate?.(this as unknown as ModelHookInstance, this.getChanges())
13121323
13131324 const changes = this.getChanges()
13141325 const changeKeys = Object.keys(changes)
13151326 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`)
13161331 const sets = changeKeys.map(k => `${k} = ?`).join(', ')
13171332 const values = [...Object.values(changes), this._attributes[pk]]
13181333
@@ -1375,6 +1390,15 @@ class ModelInstance<
13751390 }
13761391 }
13771392
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
13781402 if (timestampsEnabled(this._definition)) {
13791403 const now = formatNow()
13801404 // An explicitly supplied `created_at` wins.
@@ -1416,8 +1440,12 @@ class ModelInstance<
14161440 for (const [key, value] of Object.entries(data)) {
14171441 this._attributes[key] = value
14181442 }
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)
14201447 this._attributes[pk] = result.lastInsertId
1448 this._exists = true
14211449
14221450 await hooks?.afterCreate?.(this as unknown as ModelHookInstance)
14231451 }
@@ -1446,7 +1474,7 @@ class ModelInstance<
14461474 if (id == null) return null
14471475 const row = await exec.get(`SELECT * FROM ${this._definition.table} WHERE ${pk} = ?`, [id])
14481476 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)
14501478 }
14511479
14521480 async delete(): Promise<boolean> {
@@ -1569,7 +1597,7 @@ class ModelInstance<
15691597 delete attrs.uuid
15701598 delete attrs.created_at
15711599 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)
15731601 }
15741602
15751603 toArray(): Record<string, unknown> {
@@ -2280,7 +2308,7 @@ export class BelongsToManyRelationBuilder<TRel extends ModelDefinition> {
22802308 else if (k !== fkParent && k !== fkRelated)
22812309 pivotExtras[k] = v
22822310 }
2283 const inst = new ModelInstance(this._relatedDef, relatedRow as any)
2311 const inst = new ModelInstance(this._relatedDef, relatedRow as any, true)
22842312 ;(inst as any).pivot = pivotExtras
22852313 return inst
22862314 })
@@ -3036,7 +3064,7 @@ class ModelQueryBuilder<
30363064 }
30373065 for (const instance of instances) {
30383066 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)))
30403068 }
30413069 }
30423070 else {
@@ -3051,7 +3079,7 @@ class ModelQueryBuilder<
30513079 }
30523080 for (const instance of instances) {
30533081 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)
30553083 }
30563084 }
30573085 }
@@ -3083,7 +3111,7 @@ class ModelQueryBuilder<
30833111 for (const instance of instances) {
30843112 const fkVal = (instance as any)._attributes[rel.foreignKey]
30853113 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)
30873115 }
30883116 }
30893117
@@ -3144,7 +3172,7 @@ class ModelQueryBuilder<
31443172 .map((p) => {
31453173 const relRow = relatedByPk.get(p[rel.pivotFkRelated!])
31463174 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)
31483176 // Attach pivot extras under instance.pivot
31493177 const extras: Record<string, unknown> = {}
31503178 for (const [k, v] of Object.entries(p)) {
@@ -3197,7 +3225,7 @@ class ModelQueryBuilder<
31973225 const parentVal = throughToParent.get(row[rel.targetForeignKey!])
31983226 if (parentVal == null) continue
31993227 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))
32013229 }
32023230
32033231 for (const instance of instances) {
@@ -3211,7 +3239,7 @@ class ModelQueryBuilder<
32113239 const exec = getExecutor()
32123240 const { sql, params } = this.buildQuery()
32133241 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))
32153243
32163244 // Eager load relations
32173245 if (this._withRelations.length > 0) {
@@ -3710,7 +3738,7 @@ function createModelInternal<const TDef extends ModelDefinition>(definition: TDe
37103738 // Exclude soft-deleted rows by default (use withTrashed()/query() to override).
37113739 const sd = softDeletesEnabled(definition) ? ` AND ${SOFT_DELETE_COLUMN} IS NULL` : ''
37123740 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
37143742 },
37153743
37163744 async findOrFail(id: number | string): Promise<ModelRecord<TDef>> {
@@ -3724,7 +3752,7 @@ function createModelInternal<const TDef extends ModelDefinition>(definition: TDe
37243752 const pk = definition.primaryKey || 'id'
37253753 const sd = softDeletesEnabled(definition) ? ` AND ${SOFT_DELETE_COLUMN} IS NULL` : ''
37263754 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>)
37283756 },
37293757
37303758 all: () => new ModelQueryBuilder<TDef>(definition).get(),
@@ -3745,7 +3773,7 @@ function createModelInternal<const TDef extends ModelDefinition>(definition: TDe
37453773 },
37463774
37473775 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)
37493777 await instance.save()
37503778 return instance as ModelRecord<TDef>
37513779 },
@@ -3843,7 +3871,7 @@ function createModelInternal<const TDef extends ModelDefinition>(definition: TDe
38433871 getTable: () => definition.table,
38443872
38453873 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)
38473875 },
38483876
38493877 latest: (column: Cols = 'created_at' as Cols) => new ModelQueryBuilder<TDef>(definition).orderByDesc(column),