Skip to content
10 changes: 9 additions & 1 deletion src/dialect.lua
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ end
---@field autoincrement string
---@field table_suffix string
---@field types table<string, string>
---@field index_if_not_exists boolean Whether `CREATE INDEX IF NOT EXISTS` is valid syntax.
---@field defaults_on_text boolean Whether TEXT/BLOB/JSON columns accept a literal DEFAULT.

---@type NormDialect
dialect.mysql = {
Expand All @@ -26,8 +28,12 @@ dialect.mysql = {
placeholder = function() return "?"; end,
autoincrement = "AUTO_INCREMENT",
table_suffix = " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
-- Stock MySQL 8 supports neither; MariaDB does support the index form, but the
-- adapter cannot tell them apart at DDL time, so assume the stricter engine.
index_if_not_exists = false,
defaults_on_text = false,
types = {
id = "INT", integer = "INT", bigint = "BIGINT", string = "VARCHAR",
id = "INT", integer = "INT", bigint = "BIGINT", string = "VARCHAR(255)",
text = "TEXT", float = "FLOAT", double = "DOUBLE", boolean = "TINYINT(1)",
datetime = "DATETIME", date = "DATE", json = "JSON",
},
Expand All @@ -40,6 +46,8 @@ dialect.sqlite = {
placeholder = function() return "?"; end,
autoincrement = "AUTOINCREMENT",
table_suffix = "",
index_if_not_exists = true,
defaults_on_text = true,
types = {
id = "INTEGER", integer = "INTEGER", bigint = "INTEGER", string = "TEXT",
text = "TEXT", float = "REAL", double = "REAL", boolean = "INTEGER",
Expand Down
17 changes: 13 additions & 4 deletions src/model.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1309,7 +1309,10 @@ function NormModel:sync()
local statements = { sqlmod.create_table(self.table, self.columns, d, fks) };
if (self.indexes) then
for _, ix in ipairs(self.indexes) do
statements[#statements + 1] = sqlmod.add_index(self.table, ix.name, ix.columns, ix.unique, d, true);
statements[#statements + 1] = {
sql = sqlmod.add_index(self.table, ix.name, ix.columns, ix.unique, d, true),
optional = (d.index_if_not_exists == false),
};
end
end
-- Schema prep: bypass the readiness queue (like orm:sync) and flush on success.
Expand All @@ -1318,9 +1321,15 @@ function NormModel:sync()
local function step()
i = i + 1;
if (i > #statements) then orm:_flush_ready(); return resolve(true); end
orm:_trace(statements[i], {});
orm.adapter:raw_execute(statements[i], {}, function(err)
if (err ~= nil) then return reject(err); end
local entry = statements[i];
local statement = (type(entry) == "table") and entry.sql or entry;
local optional = (type(entry) == "table") and entry.optional;
orm:_trace(statement, {});
orm.adapter:raw_execute(statement, {}, function(err)
if (err ~= nil) then
if (not optional) then return reject(err); end
orm._logger("DB", ("index statement skipped: %s"):format(tostring(err)));
end
step();
end);
end
Expand Down
29 changes: 20 additions & 9 deletions src/orm.lua
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ function NormOrm:_m2m_fetch(model, rel, keys, spec, cb)

-- 1) pivot rows: parent key + related key.
local pstate = { table = through, columns = { pivot_main, pivot_other },
wheres = { { column = pivot_main, op = "IN", value = keys } } };
wheres = { { column = pivot_main, op = "IN", value = keys, system = true } } };
local pstmt, pparams = sqlmod.select(pstate, d);
self:_trace(pstmt, pparams);
self:_raw_query(pstmt, pparams, function(perr, prows)
Expand All @@ -312,7 +312,7 @@ function NormOrm:_m2m_fetch(model, rel, keys, spec, cb)

-- 2) target rows, fetched once by their local key (+ any include filters/order).
local tstate = { table = target.table,
wheres = { { column = other_local, op = "IN", value = related_ids } } };
wheres = { { column = other_local, op = "IN", value = related_ids, system = true } } };
if (spec and spec.wheres) then for i = 1, #spec.wheres do tstate.wheres[#tstate.wheres + 1] = spec.wheres[i]; end end
if (spec and spec.orders and #spec.orders > 0) then tstate.orders = spec.orders; end
utils.soft_scope(tstate, target); -- exclude soft-deleted related rows
Expand Down Expand Up @@ -433,7 +433,7 @@ function NormOrm:_load_include_batch(model, mains, name, spec, cb)

if (rel.kind == "belongs_to") then
local other_key = rel.otherKey or target.primary_key;
local state = { table = target.table, wheres = { { column = other_key, op = "IN", value = keys } } };
local state = { table = target.table, wheres = { { column = other_key, op = "IN", value = keys, system = true } } };
if (spec and spec.wheres) then for i = 1, #spec.wheres do state.wheres[#state.wheres + 1] = spec.wheres[i]; end end
utils.soft_scope(state, target); -- exclude soft-deleted related rows
local statement, params = sqlmod.select(state, d);
Expand All @@ -450,7 +450,7 @@ function NormOrm:_load_include_batch(model, mains, name, spec, cb)
end

-- has_one / has_many: group target rows by their foreign key.
local state = { table = target.table, wheres = { { column = rel.key, op = "IN", value = keys } } };
local state = { table = target.table, wheres = { { column = rel.key, op = "IN", value = keys, system = true } } };
if (spec and spec.wheres) then for i = 1, #spec.wheres do state.wheres[#state.wheres + 1] = spec.wheres[i]; end end
if (spec and spec.orders and #spec.orders > 0) then state.orders = spec.orders; end
utils.soft_scope(state, target); -- exclude soft-deleted related rows
Expand Down Expand Up @@ -698,10 +698,13 @@ function NormOrm:sync()
local m = self.models[name];
local fks = emit_fk and self:_collect_foreign_keys(m) or nil;
statements[#statements + 1] = sqlmod.create_table(m.table, m.columns, d, fks);
-- each table's indexes, right after it's created (idempotent via IF NOT EXISTS).
-- each table's indexes, right after it's created.
if (m.indexes) then
for _, ix in ipairs(m.indexes) do
statements[#statements + 1] = sqlmod.add_index(m.table, ix.name, ix.columns, ix.unique, d, true);
statements[#statements + 1] = {
sql = sqlmod.add_index(m.table, ix.name, ix.columns, ix.unique, d, true),
optional = (d.index_if_not_exists == false),
};
end
end
end
Expand All @@ -714,9 +717,17 @@ function NormOrm:sync()
self:_flush_ready(); -- schema prepared: release any queued data ops
return resolve(true);
end
self:_trace(statements[index], {});
self.adapter:raw_execute(statements[index], {}, function(err)
if (err ~= nil) then return reject(err); end
local entry = statements[index];
local statement = (type(entry) == "table") and entry.sql or entry;
local optional = (type(entry) == "table") and entry.optional;
self:_trace(statement, {});
self.adapter:raw_execute(statement, {}, function(err)
if (err ~= nil) then
-- Without IF NOT EXISTS support, re-creating an existing index is
-- the expected outcome of a second sync(), not a schema failure.
if (not optional) then return reject(err); end
self._logger("DB", ("index statement skipped: %s"):format(tostring(err)));
end
step();
end);
end
Expand Down
60 changes: 41 additions & 19 deletions src/query.lua
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ function NormQueryBuilder:_effective_state()
local wheres = {};
for i = 1, #self._state.wheres do wheres[i] = self._state.wheres[i]; end
-- nil value -> compiled as IS NULL ("=") / IS NOT NULL ("!=").
wheres[#wheres + 1] = { column = model.soft_deletes, op = (trashed == "only") and "!=" or "=", bool = "AND" };
wheres[#wheres + 1] = { column = model.soft_deletes, op = (trashed == "only") and "!=" or "=", bool = "AND", system = true };
s.wheres = wheres;
return s;
end
Expand Down Expand Up @@ -101,7 +101,7 @@ local function relation_subquery(self, rel, inner_select, configure)
for i = 1, #sub._state.wheres do wheres[#wheres + 1] = sub._state.wheres[i]; end
end
if (target.soft_deletes) then
wheres[#wheres + 1] = { raw = sqlmod.quote_ref(d, target.table .. "." .. target.soft_deletes) .. " IS NULL" };
wheres[#wheres + 1] = { raw = sqlmod.quote_ref(d, target.table .. "." .. target.soft_deletes) .. " IS NULL", system = true };
end

if (rel.kind == "belongs_to_many") then
Expand All @@ -111,7 +111,7 @@ local function relation_subquery(self, rel, inner_select, configure)
local other_local = rel.otherLocalKey or target.primary_key;
local local_key = rel.localKey or model.primary_key;
table.insert(wheres, 1, { raw = sqlmod.quote_ref(d, through .. "." .. pivot_main)
.. " = " .. sqlmod.quote_ref(d, model.table .. "." .. local_key) });
.. " = " .. sqlmod.quote_ref(d, model.table .. "." .. local_key), system = true });
local clause = sqlmod.compile_where(wheres, d, params);
local from = ("%s INNER JOIN %s ON %s = %s"):format(
d.quote(through), d.quote(target.table),
Expand All @@ -130,7 +130,7 @@ local function relation_subquery(self, rel, inner_select, configure)
corr = sqlmod.quote_ref(d, target.table .. "." .. rel.key)
.. " = " .. sqlmod.quote_ref(d, model.table .. "." .. local_key);
end
table.insert(wheres, 1, { raw = corr });
table.insert(wheres, 1, { raw = corr, system = true });
local clause = sqlmod.compile_where(wheres, d, params);
return ("(SELECT %s FROM %s%s)"):format(inner_select, d.quote(target.table), clause), params;
end
Expand Down Expand Up @@ -287,15 +287,25 @@ end
---@param value? any
---@param bool "AND"|"OR"
---@return NormQueryBuilder self
local function push_where(self, column, op, value, bool)
local function push_where(self, argc, column, op, value, bool)
if (type(column) == "table") then
for k, v in pairs(column) do
self._state.wheres[#self._state.wheres + 1] =
{ column = k, op = "=", value = v, bool = bool };
end
return self;
end
if (value == nil) then value = op; op = "="; end
-- Two-arg form: the operator slot actually holds the value. Decided on the
-- argument count, not on `value == nil`, so `where(col, ">", nil)` raises
-- instead of silently comparing the column to the string ">".
if (argc < 3) then
value = op;
op = "=";
else
utils.assert(value ~= nil,
("where('%s', '%s', nil): a nil value has no operator form; use where_null/where_not_null")
:format(tostring(column), tostring(op)));
end
self._state.wheres[#self._state.wheres + 1] =
{ column = column, op = op, value = value, bool = bool };
return self;
Expand All @@ -308,10 +318,10 @@ end
--- ```
---@param column string|table<string, any>
---@param op? string Operator, or the value when called with 2 args.
---@param value? any
---@param ... any The value, when called with 3 args.
---@return NormQueryBuilder self
function NormQueryBuilder:where(column, op, value)
return push_where(self, column, op, value, "AND");
function NormQueryBuilder:where(column, op, ...)
return push_where(self, select("#", ...) + 2, column, op, (...), "AND");
end

--- OR variant of `where`.
Expand All @@ -320,10 +330,10 @@ end
--- ```
---@param column string|table<string, any>
---@param op? string
---@param value? any
---@param ... any The value, when called with 3 args.
---@return NormQueryBuilder self
function NormQueryBuilder:or_where(column, op, value)
return push_where(self, column, op, value, "OR");
function NormQueryBuilder:or_where(column, op, ...)
return push_where(self, select("#", ...) + 2, column, op, (...), "OR");
end

-- Append a where condition with an explicit conjunction.
Expand Down Expand Up @@ -542,10 +552,17 @@ end
--- ```
---@param expr string Raw SQL expression (not quoted).
---@param op? string Operator, or the value when called with 2 args.
---@param value? any
---@return NormQueryBuilder self
function NormQueryBuilder:having(expr, op, value)
if (value == nil) then value = op; op = "="; end
---@param ... any The value, when called with 3 args.
---@return NormQueryBuilder self
function NormQueryBuilder:having(expr, op, ...)
local value = (...);
if (select("#", ...) == 0) then
value = op;
op = "=";
else
utils.assert(value ~= nil,
("having('%s', '%s', nil): a nil value cannot be bound"):format(tostring(expr), tostring(op)));
end
self._state.havings = self._state.havings or {};
self._state.havings[#self._state.havings + 1] = { expr = expr, op = op, value = value };
return self;
Expand Down Expand Up @@ -586,10 +603,14 @@ end
--- ```
---@return NormRecordOrNilPromise promise resolving to NormRecord|nil
function NormQueryBuilder:first()
self._state.limit = 1;
local model = self.model;
local includes = self._state.includes;
local state, counts = self:_prepare_counts(self:_effective_state());
local prepared, counts = self:_prepare_counts(self:_effective_state());
-- LIMIT 1 belongs to this call only. Writing it into _state made it stick, so
-- a later :all() on the same builder silently returned a single row.
local state = {};
for k, v in pairs(prepared) do state[k] = v; end
state.limit = 1;
if (includes and next(includes) ~= nil) then
return model.orm:_query_with_includes(model, state, includes, true);
end
Expand Down Expand Up @@ -640,7 +661,8 @@ function NormQueryBuilder:paginate(page, per_page)
local d = orm.adapter:get_dialect();

local effective = self:_effective_state();
local count_sql, count_params = sqlmod.count({ table = effective.table, wheres = effective.wheres }, d);
local count_sql, count_params = sqlmod.count(
{ table = effective.table, wheres = effective.wheres, joins = effective.joins }, d);

local data_state, counts = self:_prepare_counts(effective);
local ds = {};
Expand Down
Loading
Loading