diff --git a/src/model.lua b/src/model.lua
index f9bb2db..9a56ce8 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
@@ -1131,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)
@@ -1247,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
@@ -1283,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/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 885b5ae..ee9df85 100644
--- a/tests/selftest.lua
+++ b/tests/selftest.lua
@@ -1733,6 +1733,94 @@ 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));
+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));
+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));
+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));