lamix 4.2.11 → 4.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +389 -148
- package/package.json +2 -3
- package/examples/Alternative.md +0 -23
- package/examples/CRUD.md +0 -51
- package/examples/Post.md +0 -28
- package/examples/PostController.md +0 -93
- package/examples/Query Builder.md +0 -55
- package/examples/Relations.md +0 -16
- package/examples/Role.md +0 -26
- package/examples/RoleController.md +0 -65
- package/examples/Usages.md +0 -132
- package/examples/User.md +0 -42
- package/examples/UserController.md +0 -98
package/lib/index.js
CHANGED
|
@@ -232,31 +232,51 @@ class DB {
|
|
|
232
232
|
|
|
233
233
|
try {
|
|
234
234
|
const result = await executor();
|
|
235
|
+
|
|
235
236
|
this._emit('query', {
|
|
236
237
|
sql,
|
|
237
238
|
bindings,
|
|
238
239
|
time: performance.now() - start,
|
|
239
240
|
driver: this.driver
|
|
240
241
|
});
|
|
242
|
+
|
|
241
243
|
return result;
|
|
242
244
|
} catch (err) {
|
|
243
|
-
|
|
245
|
+
const isConnectionError =
|
|
246
|
+
err?.code === 'PROTOCOL_CONNECTION_LOST' ||
|
|
247
|
+
err?.code === 'ECONNRESET' ||
|
|
248
|
+
err?.code === 'ECONNREFUSED' ||
|
|
249
|
+
err?.code === 'ETIMEDOUT' ||
|
|
250
|
+
/dead|lost|timeout|reset|closed|connect/i.test(err.message);
|
|
251
|
+
|
|
252
|
+
this._emit('error', {
|
|
253
|
+
err,
|
|
254
|
+
sql,
|
|
255
|
+
bindings,
|
|
256
|
+
attempt,
|
|
257
|
+
connectionError: isConnectionError
|
|
258
|
+
});
|
|
244
259
|
|
|
245
260
|
const canRetry =
|
|
246
|
-
!isWrite ||
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if (
|
|
250
|
-
canRetry &&
|
|
251
|
-
attempt < this.retryAttempts &&
|
|
252
|
-
/dead|lost|timeout|reset|closed/i.test(err.message)
|
|
253
|
-
) {
|
|
261
|
+
(!isWrite || this._als.getStore()?.inTransaction) &&
|
|
262
|
+
isConnectionError;
|
|
263
|
+
|
|
264
|
+
if (canRetry && attempt < this.retryAttempts) {
|
|
254
265
|
await this.reconnect();
|
|
255
266
|
await sleep(100 * attempt);
|
|
256
267
|
continue;
|
|
257
268
|
}
|
|
258
269
|
|
|
259
|
-
throw new DBError(
|
|
270
|
+
throw new DBError(
|
|
271
|
+
isConnectionError ? 'DB connection failed' : 'DB query failed',
|
|
272
|
+
{
|
|
273
|
+
sql,
|
|
274
|
+
bindings,
|
|
275
|
+
attempt,
|
|
276
|
+
connectionError: isConnectionError,
|
|
277
|
+
err
|
|
278
|
+
}
|
|
279
|
+
);
|
|
260
280
|
}
|
|
261
281
|
}
|
|
262
282
|
}
|
|
@@ -1146,9 +1166,13 @@ class QueryBuilder {
|
|
|
1146
1166
|
|
|
1147
1167
|
// Helper to normalize operator
|
|
1148
1168
|
_normalizeOperator(operator) {
|
|
1149
|
-
const op = operator ? operator.toUpperCase() : '='
|
|
1169
|
+
const op = operator ? operator.toUpperCase() : '='
|
|
1150
1170
|
if (!VALID_OPERATORS.includes(op)) {
|
|
1151
|
-
throw new
|
|
1171
|
+
throw new DBError('Invalid SQL operator', {
|
|
1172
|
+
operator,
|
|
1173
|
+
normalized: op,
|
|
1174
|
+
method: '_normalizeOperator'
|
|
1175
|
+
});
|
|
1152
1176
|
}
|
|
1153
1177
|
|
|
1154
1178
|
// Convert ILIKE to proper operator for MySQL
|
|
@@ -1348,7 +1372,13 @@ class QueryBuilder {
|
|
|
1348
1372
|
}
|
|
1349
1373
|
|
|
1350
1374
|
whereIn(column, values = []) {
|
|
1351
|
-
if (!Array.isArray(values))
|
|
1375
|
+
if (!Array.isArray(values)) {
|
|
1376
|
+
throw new DBError('whereIn expects an array', {
|
|
1377
|
+
method: 'whereIn',
|
|
1378
|
+
column,
|
|
1379
|
+
values
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1352
1382
|
if (!values.length) {
|
|
1353
1383
|
return this._pushWhere({ type: 'raw', raw: '0 = 1', bindings: [] });
|
|
1354
1384
|
}
|
|
@@ -1391,7 +1421,13 @@ class QueryBuilder {
|
|
|
1391
1421
|
}
|
|
1392
1422
|
|
|
1393
1423
|
whereNotIn(column, values = []) {
|
|
1394
|
-
if (!Array.isArray(values))
|
|
1424
|
+
if (!Array.isArray(values)) {
|
|
1425
|
+
throw new DBError('whereNotIn expects an array', {
|
|
1426
|
+
method: 'whereNotIn',
|
|
1427
|
+
column,
|
|
1428
|
+
values
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1395
1431
|
if (!values.length) {
|
|
1396
1432
|
return this._pushWhere({ type: 'raw', raw: '1 = 1', bindings: [] });
|
|
1397
1433
|
}
|
|
@@ -1576,7 +1612,14 @@ class QueryBuilder {
|
|
|
1576
1612
|
whereHas(relationName, callback, boolean = 'AND') {
|
|
1577
1613
|
const relation = this.modelClass.relations()?.[relationName];
|
|
1578
1614
|
if (!relation) {
|
|
1579
|
-
throw new
|
|
1615
|
+
throw new DBError(
|
|
1616
|
+
`Relation '${relationName}' is not defined`,
|
|
1617
|
+
{
|
|
1618
|
+
model: this.modelClass?.name,
|
|
1619
|
+
relation: relationName,
|
|
1620
|
+
method: 'whereHas'
|
|
1621
|
+
}
|
|
1622
|
+
);
|
|
1580
1623
|
}
|
|
1581
1624
|
|
|
1582
1625
|
const RelatedModel = relation.model();
|
|
@@ -1641,6 +1684,16 @@ class QueryBuilder {
|
|
|
1641
1684
|
parts.push('SELECT');
|
|
1642
1685
|
if (this._distinct) parts.push('DISTINCT');
|
|
1643
1686
|
|
|
1687
|
+
// Normalize SELECT * to table.* when a model is attached
|
|
1688
|
+
if (
|
|
1689
|
+
this.modelClass &&
|
|
1690
|
+
this._select.length === 1 &&
|
|
1691
|
+
this._select[0] === '*'
|
|
1692
|
+
) {
|
|
1693
|
+
const table = this.tableAlias || this.table;
|
|
1694
|
+
this._select = [`${escapeId(table)}.*`];
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1644
1697
|
parts.push(this._select.length ? this._select.join(', ') : '*');
|
|
1645
1698
|
|
|
1646
1699
|
if (this._fromRaw) {
|
|
@@ -1785,7 +1838,10 @@ class QueryBuilder {
|
|
|
1785
1838
|
break;
|
|
1786
1839
|
|
|
1787
1840
|
default:
|
|
1788
|
-
throw new
|
|
1841
|
+
throw new DBError('Unknown where clause type', {
|
|
1842
|
+
type: w.type,
|
|
1843
|
+
where: w
|
|
1844
|
+
});
|
|
1789
1845
|
}
|
|
1790
1846
|
});
|
|
1791
1847
|
|
|
@@ -1823,20 +1879,29 @@ class QueryBuilder {
|
|
|
1823
1879
|
const sql = this._compileSelect();
|
|
1824
1880
|
const binds = this._gatherBindings();
|
|
1825
1881
|
|
|
1826
|
-
|
|
1882
|
+
try {
|
|
1883
|
+
const rows = await DB.raw(sql, binds);
|
|
1827
1884
|
|
|
1828
|
-
|
|
1829
|
-
|
|
1885
|
+
if (this.modelClass) {
|
|
1886
|
+
const models = rows.map(r => new this.modelClass(r, true));
|
|
1830
1887
|
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1888
|
+
if (this._with.length) {
|
|
1889
|
+
const loaded = await this._eagerLoad(models);
|
|
1890
|
+
return new Collection(loaded);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
return new Collection(models);
|
|
1834
1894
|
}
|
|
1835
1895
|
|
|
1836
|
-
return new Collection(
|
|
1896
|
+
return new Collection(rows);
|
|
1897
|
+
} catch (err) {
|
|
1898
|
+
throw new DBError('Select query failed', {
|
|
1899
|
+
table: this.table,
|
|
1900
|
+
sql,
|
|
1901
|
+
bindings: binds,
|
|
1902
|
+
err
|
|
1903
|
+
});
|
|
1837
1904
|
}
|
|
1838
|
-
|
|
1839
|
-
return new Collection(rows);
|
|
1840
1905
|
}
|
|
1841
1906
|
|
|
1842
1907
|
async first() {
|
|
@@ -1849,7 +1914,13 @@ class QueryBuilder {
|
|
|
1849
1914
|
|
|
1850
1915
|
async firstOrFail() {
|
|
1851
1916
|
const r = await this.first();
|
|
1852
|
-
if (!r)
|
|
1917
|
+
if (!r) {
|
|
1918
|
+
throw new DBError('Record not found', {
|
|
1919
|
+
method: 'firstOrFail',
|
|
1920
|
+
table: this.table,
|
|
1921
|
+
model: this.modelClass?.name
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1853
1924
|
return r;
|
|
1854
1925
|
}
|
|
1855
1926
|
|
|
@@ -1860,10 +1931,19 @@ class QueryBuilder {
|
|
|
1860
1931
|
c.limit(1);
|
|
1861
1932
|
|
|
1862
1933
|
const sql = c._compileSelect();
|
|
1863
|
-
const
|
|
1934
|
+
const bindings = c._gatherBindings();
|
|
1864
1935
|
|
|
1865
|
-
|
|
1866
|
-
|
|
1936
|
+
try {
|
|
1937
|
+
const rows = await DB.raw(sql, bindings);
|
|
1938
|
+
return rows.length > 0;
|
|
1939
|
+
} catch (err) {
|
|
1940
|
+
throw new DBError('Exists query failed', {
|
|
1941
|
+
table: this.table,
|
|
1942
|
+
sql,
|
|
1943
|
+
bindings,
|
|
1944
|
+
err
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1867
1947
|
}
|
|
1868
1948
|
|
|
1869
1949
|
async doesntExist() {
|
|
@@ -1878,11 +1958,19 @@ class QueryBuilder {
|
|
|
1878
1958
|
c._offset = null;
|
|
1879
1959
|
|
|
1880
1960
|
const sql = c._compileSelect();
|
|
1881
|
-
const
|
|
1961
|
+
const bindings = c._gatherBindings();
|
|
1882
1962
|
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1963
|
+
try {
|
|
1964
|
+
const rows = await DB.raw(sql, bindings);
|
|
1965
|
+
return rows[0] ? Number(rows[0].aggregate) : 0;
|
|
1966
|
+
} catch (err) {
|
|
1967
|
+
throw new DBError('Count query failed', {
|
|
1968
|
+
table: this.table,
|
|
1969
|
+
sql,
|
|
1970
|
+
bindings,
|
|
1971
|
+
err
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1886
1974
|
}
|
|
1887
1975
|
|
|
1888
1976
|
async _aggregate(expr) {
|
|
@@ -1893,11 +1981,20 @@ class QueryBuilder {
|
|
|
1893
1981
|
c._offset = null;
|
|
1894
1982
|
|
|
1895
1983
|
const sql = c._compileSelect();
|
|
1896
|
-
const
|
|
1984
|
+
const bindings = c._gatherBindings();
|
|
1897
1985
|
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1986
|
+
try {
|
|
1987
|
+
const rows = await DB.raw(sql, bindings);
|
|
1988
|
+
return rows[0] ? Number(rows[0].aggregate) : 0;
|
|
1989
|
+
} catch (err) {
|
|
1990
|
+
throw new DBError('Aggregate query failed', {
|
|
1991
|
+
table: this.table,
|
|
1992
|
+
expression: expr,
|
|
1993
|
+
sql,
|
|
1994
|
+
bindings,
|
|
1995
|
+
err
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1901
1998
|
}
|
|
1902
1999
|
|
|
1903
2000
|
sum(c) { return this._aggregate(`SUM(${escapeId(c)})`); }
|
|
@@ -1911,36 +2008,51 @@ class QueryBuilder {
|
|
|
1911
2008
|
c._select = [escapeId(col)];
|
|
1912
2009
|
|
|
1913
2010
|
const sql = c._compileSelect();
|
|
1914
|
-
const
|
|
1915
|
-
|
|
1916
|
-
const rows = await DB.raw(sql, b);
|
|
2011
|
+
const bindings = c._gatherBindings();
|
|
1917
2012
|
|
|
1918
|
-
|
|
2013
|
+
try {
|
|
2014
|
+
const rows = await DB.raw(sql, bindings);
|
|
2015
|
+
return rows.map(r => r[col]);
|
|
2016
|
+
} catch (err) {
|
|
2017
|
+
throw new DBError('Pluck query failed', {
|
|
2018
|
+
table: this.table,
|
|
2019
|
+
column: col,
|
|
2020
|
+
sql,
|
|
2021
|
+
bindings,
|
|
2022
|
+
err
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
1919
2025
|
}
|
|
1920
2026
|
|
|
1921
2027
|
async paginate(page = 1, perPage = 15) {
|
|
1922
2028
|
page = Math.max(1, Number(page));
|
|
1923
2029
|
perPage = Math.max(1, Number(perPage));
|
|
1924
2030
|
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
2031
|
+
try {
|
|
2032
|
+
const countClone = this._clone();
|
|
2033
|
+
countClone._select = [`COUNT(*) AS aggregate`];
|
|
2034
|
+
countClone._orders = [];
|
|
2035
|
+
countClone._limit = null;
|
|
2036
|
+
countClone._offset = null;
|
|
2037
|
+
|
|
2038
|
+
const total = await countClone.count('*');
|
|
2039
|
+
|
|
2040
|
+
const offset = (page - 1) * perPage;
|
|
2041
|
+
|
|
2042
|
+
const rows = await this._clone()
|
|
2043
|
+
.limit(perPage)
|
|
2044
|
+
.offset(offset)
|
|
2045
|
+
.get();
|
|
2046
|
+
|
|
2047
|
+
return new Paginator(rows, total, page, perPage);
|
|
2048
|
+
} catch (err) {
|
|
2049
|
+
throw new DBError('Pagination failed', {
|
|
2050
|
+
table: this.table,
|
|
2051
|
+
page,
|
|
2052
|
+
perPage,
|
|
2053
|
+
err
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
1944
2056
|
}
|
|
1945
2057
|
|
|
1946
2058
|
/**************************************************************************
|
|
@@ -1955,10 +2067,18 @@ class QueryBuilder {
|
|
|
1955
2067
|
`) VALUES (${placeholders})`;
|
|
1956
2068
|
|
|
1957
2069
|
const bindings = Object.values(values);
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
2070
|
+
try {
|
|
2071
|
+
const result = await DB.raw(sql, bindings);
|
|
2072
|
+
|
|
2073
|
+
return result.affectedRows || 0;
|
|
2074
|
+
} catch (err) {
|
|
2075
|
+
throw new DBError('Insert failed', {
|
|
2076
|
+
table: this.table,
|
|
2077
|
+
sql,
|
|
2078
|
+
bindings,
|
|
2079
|
+
err
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
1962
2082
|
}
|
|
1963
2083
|
|
|
1964
2084
|
async insertGetId(values) {
|
|
@@ -1970,15 +2090,22 @@ class QueryBuilder {
|
|
|
1970
2090
|
`) VALUES (${placeholders})`;
|
|
1971
2091
|
|
|
1972
2092
|
const bindings = Object.values(values);
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
2093
|
+
try {
|
|
2094
|
+
const result = await DB.raw(sql, bindings);
|
|
2095
|
+
|
|
2096
|
+
return result.insertId ?? null;
|
|
2097
|
+
} catch (err) {
|
|
2098
|
+
throw new DBError('Insert (get ID) failed', {
|
|
2099
|
+
table: this.table,
|
|
2100
|
+
sql,
|
|
2101
|
+
bindings,
|
|
2102
|
+
err
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
1977
2105
|
}
|
|
1978
2106
|
|
|
1979
2107
|
async update(values) {
|
|
1980
2108
|
if (!Object.keys(values).length) return 0;
|
|
1981
|
-
|
|
1982
2109
|
const setClause = Object.keys(values)
|
|
1983
2110
|
.map(k => `${escapeId(k)} = ?`)
|
|
1984
2111
|
.join(', ');
|
|
@@ -1988,10 +2115,18 @@ class QueryBuilder {
|
|
|
1988
2115
|
`UPDATE ${escapeId(this.table)} SET ${setClause} ${whereSql}`;
|
|
1989
2116
|
|
|
1990
2117
|
const bindings = [...Object.values(values), ...this._gatherBindings()];
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
2118
|
+
try {
|
|
2119
|
+
const result = await DB.raw(sql, bindings);
|
|
2120
|
+
|
|
2121
|
+
return result.affectedRows || 0;
|
|
2122
|
+
} catch (err) {
|
|
2123
|
+
throw new DBError('Update failed', {
|
|
2124
|
+
table: this.table,
|
|
2125
|
+
sql,
|
|
2126
|
+
bindings,
|
|
2127
|
+
err
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
1995
2130
|
}
|
|
1996
2131
|
|
|
1997
2132
|
async increment(col, by = 1) {
|
|
@@ -2000,11 +2135,20 @@ class QueryBuilder {
|
|
|
2000
2135
|
`SET ${escapeId(col)} = ${escapeId(col)} + ? ` +
|
|
2001
2136
|
this._compileWhereOnly();
|
|
2002
2137
|
|
|
2003
|
-
const
|
|
2004
|
-
|
|
2005
|
-
const res = await DB.raw(sql, b);
|
|
2138
|
+
const bindings = [by, ...this._gatherBindings()];
|
|
2006
2139
|
|
|
2007
|
-
|
|
2140
|
+
try {
|
|
2141
|
+
const res = await DB.raw(sql, bindings);
|
|
2142
|
+
return res.affectedRows || 0;
|
|
2143
|
+
} catch (err) {
|
|
2144
|
+
throw new DBError('Increment failed', {
|
|
2145
|
+
table: this.table,
|
|
2146
|
+
column: col,
|
|
2147
|
+
sql,
|
|
2148
|
+
bindings,
|
|
2149
|
+
err
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2008
2152
|
}
|
|
2009
2153
|
|
|
2010
2154
|
async decrement(col, by = 1) {
|
|
@@ -2013,9 +2157,20 @@ class QueryBuilder {
|
|
|
2013
2157
|
`SET ${escapeId(col)} = ${escapeId(col)} - ? ` +
|
|
2014
2158
|
this._compileWhereOnly();
|
|
2015
2159
|
|
|
2016
|
-
const
|
|
2017
|
-
|
|
2018
|
-
|
|
2160
|
+
const bindings = [by, ...this._gatherBindings()];
|
|
2161
|
+
|
|
2162
|
+
try {
|
|
2163
|
+
const res = await DB.raw(sql, bindings);
|
|
2164
|
+
return res.affectedRows || 0;
|
|
2165
|
+
} catch (err) {
|
|
2166
|
+
throw new DBError('Decrement failed', {
|
|
2167
|
+
table: this.table,
|
|
2168
|
+
column: col,
|
|
2169
|
+
sql,
|
|
2170
|
+
bindings,
|
|
2171
|
+
err
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2019
2174
|
}
|
|
2020
2175
|
|
|
2021
2176
|
async delete() {
|
|
@@ -2023,16 +2178,34 @@ class QueryBuilder {
|
|
|
2023
2178
|
`DELETE FROM ${escapeId(this.table)} ` +
|
|
2024
2179
|
this._compileWhereOnly();
|
|
2025
2180
|
|
|
2026
|
-
const
|
|
2181
|
+
const bindings = this._gatherBindings();
|
|
2027
2182
|
|
|
2028
|
-
|
|
2029
|
-
|
|
2183
|
+
try {
|
|
2184
|
+
const res = await DB.raw(sql, bindings);
|
|
2185
|
+
return res.affectedRows || 0;
|
|
2186
|
+
} catch (err) {
|
|
2187
|
+
throw new DBError('Delete failed', {
|
|
2188
|
+
table: this.table,
|
|
2189
|
+
sql,
|
|
2190
|
+
bindings,
|
|
2191
|
+
err
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2030
2194
|
}
|
|
2031
2195
|
|
|
2032
2196
|
async truncate() {
|
|
2033
2197
|
const sql = `TRUNCATE TABLE ${escapeId(this.table)}`;
|
|
2034
|
-
|
|
2035
|
-
|
|
2198
|
+
|
|
2199
|
+
try {
|
|
2200
|
+
await DB.raw(sql);
|
|
2201
|
+
return true;
|
|
2202
|
+
} catch (err) {
|
|
2203
|
+
throw new DBError('Truncate failed', {
|
|
2204
|
+
table: this.table,
|
|
2205
|
+
sql,
|
|
2206
|
+
err
|
|
2207
|
+
});
|
|
2208
|
+
}
|
|
2036
2209
|
}
|
|
2037
2210
|
|
|
2038
2211
|
_compileWhereOnly() {
|
|
@@ -2044,19 +2217,23 @@ class QueryBuilder {
|
|
|
2044
2217
|
* EAGER LOAD (unchanged except robust checks)
|
|
2045
2218
|
**************************************************************************/
|
|
2046
2219
|
async _eagerLoad(models) {
|
|
2047
|
-
|
|
2048
|
-
const sample = models[0];
|
|
2049
|
-
if (!sample) return models;
|
|
2220
|
+
if (!models.length) return models;
|
|
2050
2221
|
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
}
|
|
2222
|
+
for (const relName of this._with) {
|
|
2223
|
+
const sample = models.find(m => typeof m[relName] === 'function');
|
|
2224
|
+
if (!sample) continue;
|
|
2055
2225
|
|
|
2056
|
-
const relation =
|
|
2226
|
+
const relation = sample[relName]();
|
|
2057
2227
|
|
|
2058
2228
|
if (!relation || typeof relation.eagerLoad !== 'function') {
|
|
2059
|
-
throw new
|
|
2229
|
+
throw new DBError(
|
|
2230
|
+
`Relation "${relName}" is not eager-loadable`,
|
|
2231
|
+
{
|
|
2232
|
+
model: sample.constructor.name,
|
|
2233
|
+
relation: relName,
|
|
2234
|
+
method: 'eagerLoad'
|
|
2235
|
+
}
|
|
2236
|
+
);
|
|
2060
2237
|
}
|
|
2061
2238
|
|
|
2062
2239
|
await relation.eagerLoad(models, relName);
|
|
@@ -2802,28 +2979,57 @@ class Model {
|
|
|
2802
2979
|
this._relations = {};
|
|
2803
2980
|
this._exists = !!fresh;
|
|
2804
2981
|
|
|
2805
|
-
//
|
|
2982
|
+
// ──────────────────────────────
|
|
2983
|
+
// 1️⃣ hydrate attributes safely
|
|
2984
|
+
// ──────────────────────────────
|
|
2806
2985
|
for (const [k, v] of Object.entries(attributes)) {
|
|
2807
|
-
if (v !== undefined)
|
|
2986
|
+
if (v !== undefined) {
|
|
2987
|
+
this._attributes[k] = v;
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
// ──────────────────────────────
|
|
2992
|
+
// 2️⃣ ensure timestamps ALWAYS exist
|
|
2993
|
+
// ──────────────────────────────
|
|
2994
|
+
if (this.constructor.timestamps) {
|
|
2995
|
+
if ('created_at' in attributes)
|
|
2996
|
+
this._attributes.created_at = attributes.created_at;
|
|
2997
|
+
|
|
2998
|
+
if ('updated_at' in attributes)
|
|
2999
|
+
this._attributes.updated_at = attributes.updated_at;
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
// ──────────────────────────────
|
|
3003
|
+
// 3️⃣ always keep primary key
|
|
3004
|
+
// ──────────────────────────────
|
|
3005
|
+
const pk = this.constructor.primaryKey;
|
|
3006
|
+
if (pk && pk in attributes) {
|
|
3007
|
+
this._attributes[pk] = attributes[pk];
|
|
2808
3008
|
}
|
|
2809
3009
|
|
|
2810
3010
|
this._original = { ...this._attributes, ...data };
|
|
2811
3011
|
|
|
2812
|
-
//
|
|
3012
|
+
// ──────────────────────────────
|
|
3013
|
+
// 4️⃣ define getters/setters FOR ALL ATTRIBUTES
|
|
3014
|
+
// ──────────────────────────────
|
|
2813
3015
|
for (const k of Object.keys(this._attributes)) {
|
|
2814
3016
|
if (!(k in this)) {
|
|
2815
3017
|
Object.defineProperty(this, k, {
|
|
2816
|
-
get:
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
3018
|
+
get: () => this._attributes[k],
|
|
3019
|
+
set: v => { this._attributes[k] = v; },
|
|
3020
|
+
enumerable: true,
|
|
3021
|
+
configurable: true
|
|
2820
3022
|
});
|
|
2821
3023
|
}
|
|
2822
3024
|
}
|
|
2823
3025
|
}
|
|
2824
3026
|
|
|
2825
3027
|
static async validate(data, id, ignoreId = null) {
|
|
2826
|
-
if (!Validator)
|
|
3028
|
+
if (!Validator) {
|
|
3029
|
+
throw new DBError('Validator not found', {
|
|
3030
|
+
model: this.name
|
|
3031
|
+
});
|
|
3032
|
+
}
|
|
2827
3033
|
|
|
2828
3034
|
const rules = this.rules || {};
|
|
2829
3035
|
|
|
@@ -2897,20 +3103,6 @@ class Model {
|
|
|
2897
3103
|
}
|
|
2898
3104
|
}
|
|
2899
3105
|
|
|
2900
|
-
// ──────────────────────────────
|
|
2901
|
-
// Query builder accessors
|
|
2902
|
-
// ──────────────────────────────
|
|
2903
|
-
// static query({ withTrashed = false } = {}) {
|
|
2904
|
-
// // use tableName getter (throws if missing)
|
|
2905
|
-
// const qb = new QueryBuilder(this.tableName, this);
|
|
2906
|
-
// if (this.softDeletes && !withTrashed) {
|
|
2907
|
-
// // avoid mutating shared _wheres reference
|
|
2908
|
-
// qb._wheres = Array.isArray(qb._wheres) ? qb._wheres.slice() : [];
|
|
2909
|
-
// qb._wheres.push({ raw: `${DB.escapeId(this.deletedAt)} IS NULL` });
|
|
2910
|
-
// }
|
|
2911
|
-
// return qb;
|
|
2912
|
-
// }
|
|
2913
|
-
|
|
2914
3106
|
static query({ withTrashed = false } = {}) {
|
|
2915
3107
|
const qb = new QueryBuilder(this.tableName, this);
|
|
2916
3108
|
|
|
@@ -3000,14 +3192,26 @@ class Model {
|
|
|
3000
3192
|
}
|
|
3001
3193
|
|
|
3002
3194
|
static async findManyBy(col, values = []) {
|
|
3003
|
-
if (!Array.isArray(values))
|
|
3195
|
+
if (!Array.isArray(values)) {
|
|
3196
|
+
throw new DBError('findManyBy expects an array of values', {
|
|
3197
|
+
method: 'findManyBy',
|
|
3198
|
+
column: col
|
|
3199
|
+
});
|
|
3200
|
+
}
|
|
3004
3201
|
if (!values.length) return [];
|
|
3005
3202
|
return await this.query().whereIn(col, values).get();
|
|
3006
3203
|
}
|
|
3007
3204
|
|
|
3008
3205
|
// additional common accessors
|
|
3009
3206
|
static async findMany(ids = []) {
|
|
3010
|
-
if (!Array.isArray(ids)) ids = [ids];
|
|
3207
|
+
// if (!Array.isArray(ids)) ids = [ids];
|
|
3208
|
+
if (!Array.isArray(ids)) {
|
|
3209
|
+
throw new DBError('findMany expects an array of IDs', {
|
|
3210
|
+
method: 'findMany',
|
|
3211
|
+
model: this.name,
|
|
3212
|
+
ids
|
|
3213
|
+
});
|
|
3214
|
+
}
|
|
3011
3215
|
if (!ids.length) return [];
|
|
3012
3216
|
return await this.query().whereIn(this.primaryKey, ids).get();
|
|
3013
3217
|
}
|
|
@@ -3077,7 +3281,10 @@ class Model {
|
|
|
3077
3281
|
|
|
3078
3282
|
// 3. Block empty payload
|
|
3079
3283
|
if (!Object.keys(payload).length) {
|
|
3080
|
-
throw new DBError('Attempted to create with empty payload'
|
|
3284
|
+
throw new DBError('Attempted to create with empty payload', {
|
|
3285
|
+
model: this.name,
|
|
3286
|
+
attrs
|
|
3287
|
+
});
|
|
3081
3288
|
}
|
|
3082
3289
|
|
|
3083
3290
|
// 4. Create + save
|
|
@@ -3164,9 +3371,9 @@ class Model {
|
|
|
3164
3371
|
// 🛡 SANITIZATION UTIL
|
|
3165
3372
|
// ──────────────────────────────
|
|
3166
3373
|
static isBadValue(value) {
|
|
3167
|
-
if (value === null || value === undefined
|
|
3168
|
-
if (typeof value === 'string' && !value.trim()) return true;
|
|
3169
|
-
return false;
|
|
3374
|
+
if (value === null || value === undefined) return true;
|
|
3375
|
+
if (typeof value === 'string' && !value.trim()) return true; // empty string
|
|
3376
|
+
return false; // allow 0, false, etc.
|
|
3170
3377
|
}
|
|
3171
3378
|
|
|
3172
3379
|
sanitize(attrs = {}) {
|
|
@@ -3177,7 +3384,7 @@ class Model {
|
|
|
3177
3384
|
const val = attrs[key];
|
|
3178
3385
|
|
|
3179
3386
|
if (!this.constructor.isBadValue(val)) {
|
|
3180
|
-
clean[key] = val;
|
|
3387
|
+
clean[key] = val; // keep 0, false, etc.
|
|
3181
3388
|
} else if (keepCols.includes(key)) {
|
|
3182
3389
|
clean[key] = null;
|
|
3183
3390
|
}
|
|
@@ -3191,13 +3398,24 @@ class Model {
|
|
|
3191
3398
|
// ──────────────────────────────
|
|
3192
3399
|
async fill(attrs = {}) {
|
|
3193
3400
|
const allowed = this.constructor.fillable || Object.keys(attrs);
|
|
3401
|
+
let filled = false;
|
|
3194
3402
|
|
|
3195
3403
|
for (const key of Object.keys(attrs)) {
|
|
3196
3404
|
const val = attrs[key];
|
|
3197
|
-
if (allowed.includes(key) &&
|
|
3198
|
-
this._attributes[key] = val;
|
|
3405
|
+
if (allowed.includes(key) && val !== undefined) {
|
|
3406
|
+
this._attributes[key] = val; // 0, false, null preserved
|
|
3407
|
+
filled = true;
|
|
3199
3408
|
}
|
|
3200
3409
|
}
|
|
3410
|
+
|
|
3411
|
+
if (!filled && Object.keys(attrs).length > 0) {
|
|
3412
|
+
throw new DBError('No fillable attributes provided', {
|
|
3413
|
+
model: this.constructor.name,
|
|
3414
|
+
attrs,
|
|
3415
|
+
fillable: allowed
|
|
3416
|
+
});
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3201
3419
|
return this;
|
|
3202
3420
|
}
|
|
3203
3421
|
|
|
@@ -3209,17 +3427,23 @@ class Model {
|
|
|
3209
3427
|
// INSERT – validation first
|
|
3210
3428
|
// ──────────────────────────────
|
|
3211
3429
|
async saveNew(attrs) {
|
|
3430
|
+
if (!this._exists && !Object.keys(this._attributes).length) {
|
|
3431
|
+
throw new DBError('Cannot save empty model', {
|
|
3432
|
+
model: this.constructor.name
|
|
3433
|
+
});
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3212
3436
|
const payload = this.sanitize(attrs || this._attributes);
|
|
3213
3437
|
|
|
3214
|
-
//
|
|
3438
|
+
// validate BEFORE hooks/db
|
|
3215
3439
|
await this.constructor.validate(payload);
|
|
3216
3440
|
await this.trigger('creating');
|
|
3217
3441
|
|
|
3218
3442
|
// timestamps
|
|
3219
3443
|
if (this.constructor.timestamps) {
|
|
3220
3444
|
const now = new Date();
|
|
3221
|
-
payload.created_at
|
|
3222
|
-
payload.updated_at
|
|
3445
|
+
if (payload.created_at === undefined) payload.created_at = now;
|
|
3446
|
+
if (payload.updated_at === undefined) payload.updated_at = now;
|
|
3223
3447
|
}
|
|
3224
3448
|
|
|
3225
3449
|
// soft deletes
|
|
@@ -3231,13 +3455,11 @@ class Model {
|
|
|
3231
3455
|
}
|
|
3232
3456
|
|
|
3233
3457
|
const qb = this.constructor.query();
|
|
3234
|
-
|
|
3235
3458
|
const result = await qb.insert(payload);
|
|
3236
3459
|
|
|
3237
3460
|
// handle pk
|
|
3238
3461
|
const pk = this.constructor.primaryKey;
|
|
3239
3462
|
const insertId = Array.isArray(result) ? result[0] : result;
|
|
3240
|
-
|
|
3241
3463
|
if (!(pk in payload) && insertId !== undefined) {
|
|
3242
3464
|
payload[pk] = insertId;
|
|
3243
3465
|
}
|
|
@@ -3248,12 +3470,16 @@ class Model {
|
|
|
3248
3470
|
|
|
3249
3471
|
return this;
|
|
3250
3472
|
}
|
|
3251
|
-
|
|
3252
3473
|
// ──────────────────────────────
|
|
3253
3474
|
// UPDATE – only dirty fields
|
|
3254
3475
|
// ──────────────────────────────
|
|
3255
3476
|
async save() {
|
|
3256
|
-
if (!this._exists) return this.saveNew(this._attributes);
|
|
3477
|
+
// if (!this._exists) return this.saveNew(this._attributes);
|
|
3478
|
+
if (!this._exists && !Object.keys(this._attributes).length) {
|
|
3479
|
+
throw new DBError('Cannot save empty model', {
|
|
3480
|
+
model: this.constructor.name
|
|
3481
|
+
});
|
|
3482
|
+
}
|
|
3257
3483
|
|
|
3258
3484
|
await this.trigger('updating');
|
|
3259
3485
|
|
|
@@ -3264,11 +3490,8 @@ class Model {
|
|
|
3264
3490
|
for (const key of Object.keys(attrs)) {
|
|
3265
3491
|
const val = attrs[key];
|
|
3266
3492
|
|
|
3267
|
-
if (val !== orig[key] &&
|
|
3268
|
-
if (this.constructor.softDeletes &&
|
|
3269
|
-
key === this.constructor.deletedAt) {
|
|
3270
|
-
continue;
|
|
3271
|
-
}
|
|
3493
|
+
if (val !== orig[key] && val !== undefined) { // only treat undefined as "no change"
|
|
3494
|
+
if (this.constructor.softDeletes && key === this.constructor.deletedAt) continue;
|
|
3272
3495
|
dirty[key] = val;
|
|
3273
3496
|
}
|
|
3274
3497
|
}
|
|
@@ -3285,14 +3508,10 @@ class Model {
|
|
|
3285
3508
|
|
|
3286
3509
|
// validate BEFORE db write
|
|
3287
3510
|
const pk = this.constructor.primaryKey;
|
|
3288
|
-
await this.constructor.validate(
|
|
3289
|
-
{ ...this._original, ...payload },
|
|
3290
|
-
this._attributes[pk]
|
|
3291
|
-
);
|
|
3511
|
+
await this.constructor.validate({ ...this._original, ...payload }, this._attributes[pk]);
|
|
3292
3512
|
|
|
3293
3513
|
const id = this._attributes[pk];
|
|
3294
3514
|
const qb = this.constructor.query();
|
|
3295
|
-
|
|
3296
3515
|
await qb.where(pk, id).update(payload);
|
|
3297
3516
|
|
|
3298
3517
|
this._original = { ...this.sanitize(this._attributes) };
|
|
@@ -3326,8 +3545,13 @@ class Model {
|
|
|
3326
3545
|
let rel;
|
|
3327
3546
|
try {
|
|
3328
3547
|
rel = fn.call(dummy);
|
|
3329
|
-
} catch {
|
|
3330
|
-
continue;
|
|
3548
|
+
} catch (err) {
|
|
3549
|
+
// continue;
|
|
3550
|
+
throw new DBError('Failed to resolve relation', {
|
|
3551
|
+
model: this.name,
|
|
3552
|
+
relation: name,
|
|
3553
|
+
err
|
|
3554
|
+
});
|
|
3331
3555
|
}
|
|
3332
3556
|
|
|
3333
3557
|
if (!rel) continue;
|
|
@@ -3395,8 +3619,13 @@ class Model {
|
|
|
3395
3619
|
// RESTRICT — block delete
|
|
3396
3620
|
// ──────────────────────────────────
|
|
3397
3621
|
case 'restrict':
|
|
3398
|
-
throw new
|
|
3399
|
-
`Cannot delete ${this.constructor.name}: related ${relName} exists
|
|
3622
|
+
throw new DBError(
|
|
3623
|
+
`Cannot delete ${this.constructor.name}: related ${relName} exists`,
|
|
3624
|
+
{
|
|
3625
|
+
model: this.constructor.name,
|
|
3626
|
+
relation: relName,
|
|
3627
|
+
behavior: 'restrict'
|
|
3628
|
+
}
|
|
3400
3629
|
);
|
|
3401
3630
|
|
|
3402
3631
|
// ──────────────────────────────────
|
|
@@ -3458,7 +3687,14 @@ class Model {
|
|
|
3458
3687
|
|
|
3459
3688
|
|
|
3460
3689
|
static async destroy(ids) {
|
|
3461
|
-
if (!Array.isArray(ids))
|
|
3690
|
+
if (!Array.isArray(ids)) {
|
|
3691
|
+
throw new DBError('destroy expects an array of IDs', {
|
|
3692
|
+
model: this.name,
|
|
3693
|
+
ids
|
|
3694
|
+
});
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
// if (!Array.isArray(ids)) ids = [ids];
|
|
3462
3698
|
const pk = this.primaryKey;
|
|
3463
3699
|
|
|
3464
3700
|
// --- Load models so cascade works ---
|
|
@@ -3618,7 +3854,12 @@ class Model {
|
|
|
3618
3854
|
|
|
3619
3855
|
async refresh() {
|
|
3620
3856
|
const pk = this.constructor.primaryKey;
|
|
3621
|
-
if (!this._attributes[pk]) return this;
|
|
3857
|
+
// if (!this._attributes[pk]) return this;
|
|
3858
|
+
if (!this._attributes[pk]) {
|
|
3859
|
+
throw new DBError('Cannot refresh model without primary key', {
|
|
3860
|
+
model: this.constructor.name
|
|
3861
|
+
});
|
|
3862
|
+
}
|
|
3622
3863
|
const fresh = await this.constructor.find(this._attributes[pk]);
|
|
3623
3864
|
if (fresh) {
|
|
3624
3865
|
this._attributes = { ...fresh._attributes };
|