millas 0.1.8 → 0.1.9

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.
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Aggregates
5
+ *
6
+ * Functions for use with QueryBuilder.aggregate() and .annotate().
7
+ *
8
+ * Usage:
9
+ * const { Sum, Avg, Min, Max, Count } = require('millas/src/orm/query/Aggregates');
10
+ *
11
+ * // Single aggregate value
12
+ * await Order.aggregate({ total: Sum('amount') })
13
+ * // → { total: 9430.50 }
14
+ *
15
+ * await Order.aggregate({
16
+ * total: Sum('amount'),
17
+ * average: Avg('amount'),
18
+ * lowest: Min('amount'),
19
+ * highest: Max('amount'),
20
+ * orders: Count('id'),
21
+ * })
22
+ *
23
+ * // Per-row annotation (adds a computed column to each result)
24
+ * await Post.annotate({ comment_count: Count('comments.id') })
25
+ * .where('active', true)
26
+ * .get()
27
+ * // each Post instance has .comment_count
28
+ */
29
+
30
+ class AggregateExpression {
31
+ constructor(fn, column, options = {}) {
32
+ this.fn = fn; // 'SUM' | 'AVG' | 'MIN' | 'MAX' | 'COUNT'
33
+ this.column = column;
34
+ this.distinct = options.distinct ?? false;
35
+ }
36
+
37
+ /**
38
+ * Render to a knex raw expression string for use in .select() or .count() etc.
39
+ * @param {string} alias — SQL alias for the result column
40
+ */
41
+ toSQL(alias) {
42
+ const col = this.column === '*' ? '*' : `"${this.column.replace('.', '"."')}"`;
43
+ const expr = this.distinct ? `${this.fn}(DISTINCT ${col})` : `${this.fn}(${col})`;
44
+ return alias ? `${expr} as ${alias}` : expr;
45
+ }
46
+ }
47
+
48
+ // ─── Factory helpers ──────────────────────────────────────────────────────────
49
+
50
+ const Sum = (column, opts) => new AggregateExpression('SUM', column, opts);
51
+ const Avg = (column, opts) => new AggregateExpression('AVG', column, opts);
52
+ const Min = (column, opts) => new AggregateExpression('MIN', column, opts);
53
+ const Max = (column, opts) => new AggregateExpression('MAX', column, opts);
54
+ const Count = (column = '*', opts) => new AggregateExpression('COUNT', column, opts);
55
+
56
+ module.exports = { AggregateExpression, Sum, Avg, Min, Max, Count };
@@ -0,0 +1,308 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * LookupParser
5
+ *
6
+ * Parses Django-style __ field lookups and applies them to a knex query.
7
+ *
8
+ * Supported lookup types:
9
+ *
10
+ * Comparison
11
+ * field__exact = (default, same as no suffix)
12
+ * field__not !=
13
+ * field__gt >
14
+ * field__gte >=
15
+ * field__lt <
16
+ * field__lte <=
17
+ *
18
+ * Null checks
19
+ * field__isnull IS NULL (value: true) / IS NOT NULL (value: false)
20
+ *
21
+ * Range / set
22
+ * field__in IN (array)
23
+ * field__notin NOT IN (array)
24
+ * field__between BETWEEN [min, max]
25
+ *
26
+ * String matching
27
+ * field__contains LIKE %value% (case-sensitive)
28
+ * field__icontains ILIKE %value% (case-insensitive)
29
+ * field__startswith LIKE value%
30
+ * field__istartswith ILIKE value%
31
+ * field__endswith LIKE %value
32
+ * field__iendswith ILIKE %value
33
+ * field__like LIKE <raw pattern> (you supply the %)
34
+ *
35
+ * Date / time extraction (SQLite: strftime, PG/MySQL: EXTRACT / DATE_FORMAT)
36
+ * field__year
37
+ * field__month
38
+ * field__day
39
+ * field__hour
40
+ * field__minute
41
+ * field__second
42
+ *
43
+ * Relationship traversal (requires Model.fields with references)
44
+ * profile__city__icontains → joins profiles, filters on city
45
+ *
46
+ * Usage (internal — called by QueryBuilder):
47
+ * LookupParser.apply(knexQuery, 'age__gte', 18, ModelClass)
48
+ * LookupParser.apply(knexQuery, 'profile__city', 'Nairobi', ModelClass)
49
+ */
50
+ class LookupParser {
51
+
52
+ /**
53
+ * All recognised lookup suffixes, in order from longest to shortest so
54
+ * that e.g. "icontains" is matched before a hypothetical "contains" variant.
55
+ */
56
+ static LOOKUPS = [
57
+ 'icontains', 'istartswith', 'iendswith',
58
+ 'contains', 'startswith', 'endswith', 'like',
59
+ 'isnull', 'between', 'notin', 'in',
60
+ 'exact', 'not',
61
+ 'gte', 'lte', 'gt', 'lt',
62
+ 'year', 'month', 'day', 'hour', 'minute', 'second',
63
+ ];
64
+
65
+ /**
66
+ * Parse a lookup key and apply the appropriate knex constraint.
67
+ *
68
+ * @param {object} q — knex query builder (mutated in place)
69
+ * @param {string} key — e.g. "age__gte", "profile__city__icontains"
70
+ * @param {*} value — the comparison value
71
+ * @param {class} ModelClass — the root Model class (for relationship traversal)
72
+ * @param {string} [method] — 'where' | 'orWhere' (default: 'where')
73
+ * @returns {object} the (mutated) knex query
74
+ */
75
+ static apply(q, key, value, ModelClass, method = 'where') {
76
+ // No __ at all → plain equality, pass straight through
77
+ if (!key.includes('__')) {
78
+ q[method](key, value);
79
+ return q;
80
+ }
81
+
82
+ const parts = key.split('__');
83
+ const lookup = this._extractLookup(parts);
84
+ // Everything left after stripping the lookup is the field path
85
+ const fieldPath = lookup ? parts.slice(0, -1) : parts;
86
+
87
+ // Relationship traversal: more than one segment in the field path
88
+ if (fieldPath.length > 1) {
89
+ return this._applyRelational(q, fieldPath, lookup, value, ModelClass, method);
90
+ }
91
+
92
+ const column = fieldPath[0];
93
+ return this._applyLookup(q, column, lookup || 'exact', value, method);
94
+ }
95
+
96
+ // ─── Lookup application ───────────────────────────────────────────────────
97
+
98
+ static _applyLookup(q, column, lookup, value, method) {
99
+ switch (lookup) {
100
+ case 'exact':
101
+ q[method](column, value);
102
+ break;
103
+
104
+ case 'not':
105
+ q[method](column, '!=', value);
106
+ break;
107
+
108
+ case 'gt':
109
+ q[method](column, '>', value);
110
+ break;
111
+
112
+ case 'gte':
113
+ q[method](column, '>=', value);
114
+ break;
115
+
116
+ case 'lt':
117
+ q[method](column, '<', value);
118
+ break;
119
+
120
+ case 'lte':
121
+ q[method](column, '<=', value);
122
+ break;
123
+
124
+ case 'isnull':
125
+ if (value) q[`${method}Null`] ? q[`${method}Null`](column) : q.whereNull(column);
126
+ else q[`${method}NotNull`] ? q[`${method}NotNull`](column) : q.whereNotNull(column);
127
+ break;
128
+
129
+ case 'in':
130
+ q[`${method}In`] ? q[`${method}In`](column, value) : q.whereIn(column, value);
131
+ break;
132
+
133
+ case 'notin':
134
+ q[`${method}NotIn`] ? q[`${method}NotIn`](column, value) : q.whereNotIn(column, value);
135
+ break;
136
+
137
+ case 'between':
138
+ q[`${method}Between`]
139
+ ? q[`${method}Between`](column, value)
140
+ : q.whereBetween(column, value);
141
+ break;
142
+
143
+ case 'contains':
144
+ q[method](column, 'like', `%${value}%`);
145
+ break;
146
+
147
+ case 'icontains':
148
+ q[method](column, 'ilike', `%${value}%`);
149
+ break;
150
+
151
+ case 'startswith':
152
+ q[method](column, 'like', `${value}%`);
153
+ break;
154
+
155
+ case 'istartswith':
156
+ q[method](column, 'ilike', `${value}%`);
157
+ break;
158
+
159
+ case 'endswith':
160
+ q[method](column, 'like', `%${value}`);
161
+ break;
162
+
163
+ case 'iendswith':
164
+ q[method](column, 'ilike', `%${value}`);
165
+ break;
166
+
167
+ case 'like':
168
+ q[method](column, 'like', value);
169
+ break;
170
+
171
+ // ── Date/time extractions ──────────────────────────────────────────
172
+ case 'year':
173
+ case 'month':
174
+ case 'day':
175
+ case 'hour':
176
+ case 'minute':
177
+ case 'second':
178
+ this._applyDatePart(q, column, lookup, value, method);
179
+ break;
180
+
181
+ default:
182
+ // Unrecognised suffix — treat whole key as column name, do equality
183
+ q[method](column, value);
184
+ }
185
+
186
+ return q;
187
+ }
188
+
189
+ // ─── Date part extraction — dialect-aware ─────────────────────────────────
190
+
191
+ static _applyDatePart(q, column, part, value, method) {
192
+ const client = q.client?.config?.client || 'sqlite3';
193
+
194
+ if (client.includes('pg') || client.includes('postgres')) {
195
+ // PostgreSQL: EXTRACT(YEAR FROM column) = value
196
+ const pgPart = part.toUpperCase();
197
+ q[method](q.client.raw(`EXTRACT(${pgPart} FROM "${column}")`, []), value);
198
+
199
+ } else if (client.includes('mysql') || client.includes('maria')) {
200
+ // MySQL: YEAR(column) = value etc.
201
+ const fn = part.toUpperCase();
202
+ q[method](q.client.raw(`${fn}(\`${column}\`)`, []), value);
203
+
204
+ } else {
205
+ // SQLite: strftime('%Y', column) = value
206
+ const fmtMap = {
207
+ year: '%Y', month: '%m', day: '%d',
208
+ hour: '%H', minute: '%M', second: '%S',
209
+ };
210
+ const fmt = fmtMap[part];
211
+ q[method](q.client.raw(`strftime('${fmt}', "${column}")`, []), String(value).padStart(part === 'year' ? 4 : 2, '0'));
212
+ }
213
+ }
214
+
215
+ // ─── Relationship traversal ───────────────────────────────────────────────
216
+
217
+ /**
218
+ * Handle multi-segment paths like profile__city or post__author__role.
219
+ *
220
+ * Resolves each hop using Model.fields[x].references, auto-joins the
221
+ * necessary tables, then applies the final lookup on the leaf column.
222
+ *
223
+ * @param {object} q — knex query
224
+ * @param {string[]} fieldPath — e.g. ['profile', 'city']
225
+ * @param {string} lookup — e.g. 'icontains'
226
+ * @param {*} value
227
+ * @param {class} ModelClass — root model
228
+ * @param {string} method — 'where' | 'orWhere'
229
+ */
230
+ static _applyRelational(q, fieldPath, lookup, value, ModelClass, method) {
231
+ let currentModel = ModelClass;
232
+ const joinedTables = new Set();
233
+
234
+ // Walk all segments except the last — each one is a relationship hop
235
+ for (let i = 0; i < fieldPath.length - 1; i++) {
236
+ const segment = fieldPath[i];
237
+ const fields = currentModel.fields || {};
238
+
239
+ // Try to find a field whose name matches the segment
240
+ const fieldDef = fields[segment] || fields[`${segment}_id`];
241
+
242
+ if (!fieldDef || !fieldDef.references) {
243
+ // No references info — fall back to treating the whole path as a
244
+ // raw column name with underscores (best-effort)
245
+ const fallbackCol = fieldPath.join('_');
246
+ return this._applyLookup(q, fallbackCol, lookup || 'exact', value, method);
247
+ }
248
+
249
+ const { table: relTable, column: relColumn } = fieldDef.references;
250
+ const localColumn = fieldDef.references.localKey || segment + '_id';
251
+ const joinKey = `${currentModel.table}__${relTable}`;
252
+
253
+ if (!joinedTables.has(joinKey)) {
254
+ q.join(relTable, `${currentModel.table}.${localColumn}`, '=', `${relTable}.${relColumn}`);
255
+ joinedTables.add(joinKey);
256
+ }
257
+
258
+ // Advance — try to resolve the related model for the next hop.
259
+ // We do a best-effort require() from the models directory.
260
+ currentModel = this._resolveRelatedModel(relTable) || { table: relTable, fields: {} };
261
+ }
262
+
263
+ // The final segment is the leaf column on the last joined table
264
+ const leafColumn = `${currentModel.table}.${fieldPath[fieldPath.length - 1]}`;
265
+ return this._applyLookup(q, leafColumn, lookup || 'exact', value, method);
266
+ }
267
+
268
+ /**
269
+ * Try to resolve a related Model class by table name.
270
+ * Looks in process.cwd()/app/models/ (best-effort, graceful failure).
271
+ */
272
+ static _resolveRelatedModel(tableName) {
273
+ try {
274
+ const path = require('path');
275
+ const fs = require('fs');
276
+ const modelsDir = path.join(process.cwd(), 'app', 'models');
277
+
278
+ if (!fs.existsSync(modelsDir)) return null;
279
+
280
+ const files = fs.readdirSync(modelsDir).filter(f => f.endsWith('.js'));
281
+
282
+ for (const file of files) {
283
+ try {
284
+ const cls = require(path.join(modelsDir, file));
285
+ const ModelClass = typeof cls === 'function' ? cls
286
+ : Object.values(cls).find(v => typeof v === 'function');
287
+
288
+ if (ModelClass && ModelClass.table === tableName) return ModelClass;
289
+ } catch { /* skip */ }
290
+ }
291
+ } catch { /* skip */ }
292
+
293
+ return null;
294
+ }
295
+
296
+ // ─── Helpers ──────────────────────────────────────────────────────────────
297
+
298
+ /**
299
+ * Given the split parts array, return the lookup suffix if the last
300
+ * element is a known lookup keyword, otherwise return null.
301
+ */
302
+ static _extractLookup(parts) {
303
+ const last = parts[parts.length - 1];
304
+ return this.LOOKUPS.includes(last) ? last : null;
305
+ }
306
+ }
307
+
308
+ module.exports = LookupParser;
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const LookupParser = require('./LookupParser');
4
+
5
+ /**
6
+ * Q — complex boolean query objects
7
+ *
8
+ * Lets you express any combination of AND / OR / NOT conditions,
9
+ * including deeply nested groups, in a single readable expression.
10
+ *
11
+ * Usage:
12
+ * const { Q } = require('millas');
13
+ *
14
+ * // OR
15
+ * User.filter(Q({ age__gte: 18 }).or(Q({ role: 'admin' })))
16
+ *
17
+ * // AND (default when chaining .where())
18
+ * User.filter(Q({ city: 'Nairobi' }).and(Q({ active: true })))
19
+ *
20
+ * // NOT
21
+ * User.filter(Q({ status: 'banned' }).not())
22
+ *
23
+ * // Nested
24
+ * User.filter(
25
+ * Q({ role: 'admin' }).or(
26
+ * Q({ age__gte: 18 }).and(Q({ active: true }))
27
+ * )
28
+ * )
29
+ *
30
+ * // Shorthand operators
31
+ * Q({ age__gte: 18 }).or({ role: 'admin' })
32
+ */
33
+ class Q {
34
+ /**
35
+ * @param {object} conditions — plain { field: value } or { field__lookup: value }
36
+ */
37
+ constructor(conditions = {}) {
38
+ this._conditions = conditions;
39
+ this._children = []; // [{ type: 'and'|'or', node: Q }]
40
+ this._negated = false;
41
+ }
42
+
43
+ // ─── Combinators ──────────────────────────────────────────────────────────
44
+
45
+ and(other) {
46
+ const node = other instanceof Q ? other : new Q(other);
47
+ this._children.push({ type: 'and', node });
48
+ return this;
49
+ }
50
+
51
+ or(other) {
52
+ const node = other instanceof Q ? other : new Q(other);
53
+ this._children.push({ type: 'or', node });
54
+ return this;
55
+ }
56
+
57
+ not() {
58
+ this._negated = !this._negated;
59
+ return this;
60
+ }
61
+
62
+ // ─── Application ──────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Apply this Q tree to a knex query builder.
66
+ *
67
+ * @param {object} knexQuery — knex query (mutated)
68
+ * @param {class} ModelClass — root model (for lookups / joins)
69
+ * @param {string} method — 'where' | 'orWhere'
70
+ */
71
+ apply(knexQuery, ModelClass, method = 'where') {
72
+ const applyFn = this._negated ? `${method}Not` : method;
73
+
74
+ knexQuery[method](function () {
75
+ const sub = this; // knex sub-query builder
76
+
77
+ // Apply own conditions
78
+ for (const [key, value] of Object.entries(Q.prototype._conditions
79
+ ? {}
80
+ : {})) { void key; void value; } // placeholder — see below
81
+
82
+ // Own conditions
83
+ for (const [key, value] of Object.entries(
84
+ // `this` in constructor is the Q instance captured by closure below
85
+ {}
86
+ )) { void key; void value; }
87
+
88
+ // We need the Q instance inside the knex callback — use a wrapper
89
+ });
90
+
91
+ // Re-implement without the confusing closure issue:
92
+ this._applyToBuilder(knexQuery, ModelClass, method);
93
+ return knexQuery;
94
+ }
95
+
96
+ /**
97
+ * Internal — recursively applies the Q tree.
98
+ */
99
+ _applyToBuilder(knexQuery, ModelClass, outerMethod = 'where') {
100
+ const self = this;
101
+ const wrapped = this._negated ? `${outerMethod}Not` : outerMethod;
102
+
103
+ // knex `.where(function() { ... })` creates a grouped sub-expression
104
+ knexQuery[outerMethod](function () {
105
+ const sub = this;
106
+
107
+ // Own conditions
108
+ for (const [key, value] of Object.entries(self._conditions)) {
109
+ LookupParser.apply(sub, key, value, ModelClass, 'where');
110
+ }
111
+
112
+ // Child nodes
113
+ for (const { type, node } of self._children) {
114
+ const childMethod = type === 'or' ? 'orWhere' : 'where';
115
+ node._applyToBuilder(sub, ModelClass, childMethod);
116
+ }
117
+ });
118
+
119
+ return knexQuery;
120
+ }
121
+ }
122
+
123
+ module.exports = Q;