From 190187add827724a9a3eecf99ea4249b266aad5e Mon Sep 17 00:00:00 2001 From: JustGod <85418813+JustGodWork@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:51:44 +0200 Subject: [PATCH 1/4] fix(model): decode boolean false instead of falling back to the raw value NormRecord:__init used `persisted and model:parse(col, value) or value`. When parse() returns false for a boolean column, that idiom falls through to the raw driver value, so `admin = 0` stayed the number 0, which is truthy in Lua. Every `if record.admin then` was true for non-admins. Replaced with an explicit branch and added regression tests covering 0/1 decoding and reload(). --- src/model.lua | 6 +++++- tests/selftest.lua | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/model.lua b/src/model.lua index f9bb2db..9a59ff9 100644 --- a/src/model.lua +++ b/src/model.lua @@ -89,7 +89,11 @@ function NormRecord:__init(model, row, persisted) local col = model.columns[i]; local value = row[col.name]; if (value ~= nil) then - self[col.name] = self.__persisted and model:parse(col, value) or value; + if (self.__persisted) then + self[col.name] = model:parse(col, value); + else + self[col.name] = value; + end end end end diff --git a/tests/selftest.lua b/tests/selftest.lua index 885b5ae..59bf80a 100644 --- a/tests/selftest.lua +++ b/tests/selftest.lua @@ -1733,6 +1733,24 @@ fdb:define("t", { id = orm.types.id(), code = orm.types.string({ length = 8, ind local synced, sync_err = nil, nil; fdb:sync():next(function(v) synced = v; end, function(e) sync_err = e; end); check("a duplicate index does not fail sync on mysql", synced == true and sync_err == nil, tostring(sync_err)); +print("== Test group 41: boolean decoding =="); +local bm = Mock({ dialect = "mysql" }); +local bdb = orm.new({ adapter = bm, promise = orm.promise.builtin() }); +local B = bdb:define("flags", { id = orm.types.id(), admin = orm.types.boolean() }); + +bm.query_result = { { id = 1, admin = 0 } }; +local off = B:find(1):await(); +check("admin 0 decodes to boolean false", off.admin == false, tostring(off.admin)); +check("admin 0 is not truthy", not off.admin, type(off.admin)); + +bm.query_result = { { id = 2, admin = 1 } }; +local on = B:find(2):await(); +check("admin 1 decodes to boolean true", on.admin == true, tostring(on.admin)); + +bm.query_result = { { id = 3, admin = 0 } }; +local rel = B:find(3):await(); +rel:reload(); +check("reload keeps boolean false", rel.admin == false, tostring(rel.admin)); end -- close the last group's scope print(("\n== RESULT: %d passed, %d failed =="):format(passed, failed)); From 503050a89cb0e713f82028b7b4157a1a30137c5a Mon Sep 17 00:00:00 2001 From: JustGod <85418813+JustGodWork@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:02:57 +0200 Subject: [PATCH 2/4] fix(orm): return nil, not an empty table, when eager loading finds nothing Three defects in the include() path, two of them the same and/or idiom: resolve(single and nil or records) -- first() resolved {} on no match (kind == "has_one") and (g[1] or nil) or g -- empty has_one became {} Both produced a truthy empty table where the contract, and the lazy load path, return nil. `if post then post:save() end` passed the guard and then failed on the method call. The third: when no parent had a usable key, one single empty table was assigned to every parent, so inserting into one parent's collection was visible on all the others. Each parent now gets its own table. --- src/orm.lua | 20 +++++++++++++++----- tests/selftest.lua | 28 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/orm.lua b/src/orm.lua index 18e0de9..b10f113 100644 --- a/src/orm.lua +++ b/src/orm.lua @@ -416,9 +416,13 @@ function NormOrm:_load_include_batch(model, mains, name, spec, cb) if (v ~= nil and not seen[v]) then seen[v] = true; keys[#keys + 1] = v; end end - local empty = (rel.kind == "has_many" or rel.kind == "belongs_to_many") and {} or nil; + local wants_list = (rel.kind == "has_many" or rel.kind == "belongs_to_many"); if (#keys == 0) then - for i = 1, #mains do mains[i][name] = empty; end + -- A fresh table per parent: one shared table would make a mutation on any + -- parent's collection visible on all the others. + for i = 1, #mains do + if (wants_list) then mains[i][name] = {}; else mains[i][name] = nil; end + end return cb(); end @@ -469,7 +473,11 @@ function NormOrm:_load_include_batch(model, mains, name, spec, cb) for i = 1, #mains do local g = groups[mains[i][source_key]] or {}; if (spec and spec.limit and rel.kind == "has_many") then g = slice(g, spec.offset, spec.limit); end - mains[i][name] = (rel.kind == "has_one") and (g[1] or nil) or g; + if (rel.kind == "has_one") then + mains[i][name] = g[1]; + else + mains[i][name] = g; + end end cb(); end); @@ -494,12 +502,14 @@ function NormOrm:_query_with_includes(model, state, includes, single) local records = {}; for i = 1, #rows do records[i] = model:wrap(rows[i]); end if (#records == 0) then - return resolve(single and nil or records); + if (single) then return resolve(nil); end + return resolve(records); end local ok, perr = pcall(function() self:_load_includes(model, records, includes, function(e) if (e ~= nil) then return reject(e); end - resolve(single and records[1] or records); + if (single) then return resolve(records[1]); end + resolve(records); end); end); if (not ok) then reject(perr); end diff --git a/tests/selftest.lua b/tests/selftest.lua index 59bf80a..22c1959 100644 --- a/tests/selftest.lua +++ b/tests/selftest.lua @@ -1751,6 +1751,34 @@ bm.query_result = { { id = 3, admin = 0 } }; local rel = B:find(3):await(); rel:reload(); check("reload keeps boolean false", rel.admin == false, tostring(rel.admin)); +print("== Test group 35: eager loading with nothing to attach =="); +local em = Routed({ dialect = "mysql" }); +local edb = orm.new({ adapter = em, promise = orm.promise.builtin() }); +local EU = edb:define("eusers", { + id = orm.types.id(), + posts = orm.types.hasMany("eposts", { key = "user_id" }), + profile = orm.types.hasOne("eprofiles", { key = "user_id" }), +}); +edb:define("eposts", { id = orm.types.id(), user_id = orm.types.integer() }); +edb:define("eprofiles", { id = orm.types.id(), user_id = orm.types.integer() }); + +em.rows.eusers = {}; +local none = EU:query():include("posts"):first():await(); +check("first with include resolves nil when nothing matched", none == nil, tostring(none)); + +em.rows.eusers = { { id = 1 }, { id = 2 } }; +em.rows.eposts = { { id = 9, user_id = 1 } }; +em.rows.eprofiles = {}; +local list = EU:query():include("posts", "profile"):all():await(); +check("parent with related rows gets them", #list[1].posts == 1, tostring(#list[1].posts)); +check("parent without related rows gets an empty list", #list[2].posts == 0); +check("empty has_one is nil, not an empty table", list[1].profile == nil, type(list[1].profile)); + +em.rows.eusers = { { id = 3 }, { id = 4 } }; +em.rows.eposts = {}; +local orphans = EU:query():include("posts"):all():await(); +table.insert(orphans[1].posts, "x"); +check("empty collections are not shared between parents", #orphans[2].posts == 0, tostring(#orphans[2].posts)); end -- close the last group's scope print(("\n== RESULT: %d passed, %d failed =="):format(passed, failed)); From 503ebd08493f08ec75c3a1bede9c9757ad02ce3e Mon Sep 17 00:00:00 2001 From: JustGod <85418813+JustGodWork@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:03:33 +0200 Subject: [PATCH 3/4] fix(model): apply the soft-delete scope in the find_or_* lookup _find_by_attrs built its state without utils.soft_scope, unlike find, find_by and every relation loader. On a soft-deleting model, find_or_create, find_or_new and update_or_create therefore matched rows that had been deleted: instead of creating a new row they revived a trashed one, and update_or_create wrote into it. --- src/model.lua | 1 + tests/selftest.lua | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/model.lua b/src/model.lua index 9a59ff9..13c0b0d 100644 --- a/src/model.lua +++ b/src/model.lua @@ -1135,6 +1135,7 @@ function NormModel:_find_by_attrs(attributes, cb) for _, k in ipairs(utils.sorted_keys(attributes)) do state.wheres[#state.wheres + 1] = { column = k, op = "=", value = attributes[k] }; end + utils.soft_scope(state, self); -- a trashed row must not satisfy a find_or_* lookup local statement, params = sqlmod.select(state, d); orm:_trace(statement, params); orm:_raw_query(statement, params, function(err, rows) diff --git a/tests/selftest.lua b/tests/selftest.lua index 22c1959..5da3b7e 100644 --- a/tests/selftest.lua +++ b/tests/selftest.lua @@ -1779,6 +1779,29 @@ em.rows.eposts = {}; local orphans = EU:query():include("posts"):all():await(); table.insert(orphans[1].posts, "x"); check("empty collections are not shared between parents", #orphans[2].posts == 0, tostring(#orphans[2].posts)); +print("== Test group 36: find_or_* honour the soft-delete scope =="); +local fm = Mock({ dialect = "mysql" }); +local fdb2 = orm.new({ adapter = fm, promise = orm.promise.builtin() }); +local F = fdb2:define("faccounts", { id = orm.types.id(), email = orm.types.string({ length = 40 }) }, + { soft_deletes = true }); + +fm.query_result = {}; +F:find_or_new({ email = "a@b.c" }, { }); +local lookup; +for _, c in ipairs(fm.calls) do + if (c.kind == "query" and c.sql:find("SELECT", 1, true)) then lookup = c.sql; end +end +check("find_or_new excludes trashed rows", + lookup:find("`email` = ? AND `deleted_at` IS NULL", 1, true) ~= nil, tostring(lookup)); + +fm.calls = {}; +F:find_or_create({ email = "c@d.e" }, {}); +local lookup2; +for _, c in ipairs(fm.calls) do + if (c.kind == "query" and c.sql:find("SELECT", 1, true)) then lookup2 = c.sql; break; end +end +check("find_or_create excludes trashed rows", + lookup2:find("`deleted_at` IS NULL", 1, true) ~= nil, tostring(lookup2)); end -- close the last group's scope print(("\n== RESULT: %d passed, %d failed =="):format(passed, failed)); From 89ae994112eaf5c47998d2ffd295de18242cdffc Mon Sep 17 00:00:00 2001 From: JustGod <85418813+JustGodWork@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:11:46 +0200 Subject: [PATCH 4/4] fix(model): require the conflict columns in the upsert payload upsert() reads the canonical row back by its conflict columns, which default to the primary key. With an auto-increment key the caller does not supply it, so the read-back compiled to `WHERE id IS NULL` and the promise resolved nil even though the row had been written. The existing assertion only checked that the conflict list was non-empty. The columns are now required in the data, and the read-back applies the soft-delete scope like every other lookup. --- src/model.lua | 10 ++++++++++ tests/selftest.lua | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/model.lua b/src/model.lua index 13c0b0d..9a56ce8 100644 --- a/src/model.lua +++ b/src/model.lua @@ -1252,6 +1252,15 @@ function NormModel:upsert(data, opts) utils.assert(type(conflict) == "table" and #conflict > 0, ("upsert on '%s' needs conflict columns (opts.conflict) or a primary key"):format(model.table)); + -- The row is read back by the conflict columns, so a conflict column missing + -- from `data` would compile to `WHERE IS NULL` and resolve nil even + -- though the write succeeded. That happens by default whenever the model has + -- an auto-increment primary key and the caller does not supply it. + for _, c in ipairs(conflict) do + utils.assert(data[c] ~= nil, + ("upsert on '%s': conflict column '%s' is missing from the data"):format(model.table, c)); + end + -- write payload (+ timestamps for the INSERT branch). local write = {}; for k, v in pairs(data) do write[k] = v; end @@ -1288,6 +1297,7 @@ function NormModel:upsert(data, opts) for _, c in ipairs(conflict) do state.wheres[#state.wheres + 1] = { column = c, op = "=", value = write[c] }; end + utils.soft_scope(state, model); local sel, sparams = sqlmod.select(state, d); orm:_trace(sel, sparams); orm:_raw_query(sel, sparams, function(serr, rows) diff --git a/tests/selftest.lua b/tests/selftest.lua index 5da3b7e..ee9df85 100644 --- a/tests/selftest.lua +++ b/tests/selftest.lua @@ -1802,6 +1802,25 @@ for _, c in ipairs(fm.calls) do end check("find_or_create excludes trashed rows", lookup2:find("`deleted_at` IS NULL", 1, true) ~= nil, tostring(lookup2)); +print("== Test group 37: upsert needs its conflict columns =="); +local um = Mock({ dialect = "mysql" }); +local udb = orm.new({ adapter = um, promise = orm.promise.builtin() }); +local U3 = udb:define("uaccounts", { + id = orm.types.id(), + account_id = orm.types.string({ length = 32, unique = true }), + name = orm.types.string({ length = 20 }), +}); + +check("upsert without the default conflict column raises", + select(1, pcall(function() U3:upsert({ name = "Zoe" }); end)) == false); +check("upsert with an explicit conflict column missing from the data raises", + select(1, pcall(function() U3:upsert({ name = "Zoe" }, { conflict = { "account_id" } }); end)) == false); + +um.query_result = { { id = 1, account_id = "acc-1", name = "Zoe" } }; +local rec = U3:upsert({ account_id = "acc-1", name = "Zoe" }, { conflict = { "account_id" } }):await(); +check("a valid upsert still reads the row back", rec ~= nil and rec.account_id == "acc-1", tostring(rec)); +check("the read-back filters on the conflict column", + last_sql(um):find("WHERE `account_id` = ?", 1, true) ~= nil, last_sql(um)); end -- close the last group's scope print(("\n== RESULT: %d passed, %d failed =="):format(passed, failed));