tina4-nodejs 3.10.91 → 3.10.93

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.
@@ -357,10 +357,26 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
357
357
  return null;
358
358
  }
359
359
 
360
+ // Slice syntax: value[1:5], value[:10], value[start:end]
361
+ const isQuotedPart = (part.startsWith('"') && part.endsWith('"')) ||
362
+ (part.startsWith("'") && part.endsWith("'"));
363
+ if (isBracket && part.includes(":") && !isQuotedPart) {
364
+ const sliceParts = part.split(":", 2);
365
+ const sStart = sliceParts[0].trim() ? parseInt(String(evalExpr(sliceParts[0].trim(), context)), 10) : undefined;
366
+ const sEnd = sliceParts[1].trim() ? parseInt(String(evalExpr(sliceParts[1].trim(), context)), 10) : undefined;
367
+ if (Array.isArray(value)) {
368
+ value = (value as unknown[]).slice(sStart ?? 0, sEnd);
369
+ } else if (typeof value === "string") {
370
+ value = (value as string).slice(sStart ?? 0, sEnd);
371
+ } else {
372
+ return null;
373
+ }
374
+ continue;
375
+ }
376
+
360
377
  let key: string | number;
361
378
  // Check if this part came from bracket access and needs variable resolution
362
- if ((part.startsWith('"') && part.endsWith('"')) ||
363
- (part.startsWith("'") && part.endsWith("'"))) {
379
+ if (isQuotedPart) {
364
380
  // Quoted string literal — strip quotes
365
381
  key = part.slice(1, -1);
366
382
  } else {
@@ -369,7 +385,7 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
369
385
  key = asNum;
370
386
  } else if (isBracket) {
371
387
  // Only resolve as a variable from context for bracket-derived parts
372
- const resolved = context[part];
388
+ const resolved = evalExpr(part, context);
373
389
  key = resolved !== undefined ? String(resolved) : part;
374
390
  } else {
375
391
  // Dot-derived parts or root — use the part name directly as the key
@@ -397,10 +413,11 @@ function resolveVar(expr: string, context: Record<string, unknown>): unknown {
397
413
  function findOutsideQuotes(expr: string, needle: string): number {
398
414
  let inQuote: string | null = null;
399
415
  let depth = 0;
416
+ let bracketDepth = 0;
400
417
  let i = 0;
401
418
  while (i <= expr.length - needle.length) {
402
419
  const ch = expr[i];
403
- if ((ch === '"' || ch === "'") && depth === 0) {
420
+ if ((ch === '"' || ch === "'") && depth === 0 && bracketDepth === 0) {
404
421
  if (inQuote === null) {
405
422
  inQuote = ch;
406
423
  } else if (ch === inQuote) {
@@ -412,7 +429,9 @@ function findOutsideQuotes(expr: string, needle: string): number {
412
429
  if (inQuote) { i++; continue; }
413
430
  if (ch === "(") depth++;
414
431
  else if (ch === ")") depth--;
415
- if (depth === 0 && expr.slice(i, i + needle.length) === needle) {
432
+ else if (ch === "[") bracketDepth++;
433
+ else if (ch === "]") bracketDepth--;
434
+ if (depth === 0 && bracketDepth === 0 && expr.slice(i, i + needle.length) === needle) {
416
435
  return i;
417
436
  }
418
437
  i++;
@@ -425,10 +444,11 @@ function splitOutsideQuotes(expr: string, sep: string): string[] {
425
444
  let currentStart = 0;
426
445
  let inQuote: string | null = null;
427
446
  let depth = 0;
447
+ let bracketDepth = 0;
428
448
  let i = 0;
429
449
  while (i <= expr.length - sep.length) {
430
450
  const ch = expr[i];
431
- if ((ch === '"' || ch === "'") && depth === 0) {
451
+ if ((ch === '"' || ch === "'") && depth === 0 && bracketDepth === 0) {
432
452
  if (inQuote === null) {
433
453
  inQuote = ch;
434
454
  } else if (ch === inQuote) {
@@ -440,7 +460,9 @@ function splitOutsideQuotes(expr: string, sep: string): string[] {
440
460
  if (inQuote) { i++; continue; }
441
461
  if (ch === "(") depth++;
442
462
  else if (ch === ")") depth--;
443
- if (depth === 0 && expr.slice(i, i + sep.length) === sep) {
463
+ else if (ch === "[") bracketDepth++;
464
+ else if (ch === "]") bracketDepth--;
465
+ if (depth === 0 && bracketDepth === 0 && expr.slice(i, i + sep.length) === sep) {
444
466
  parts.push(expr.slice(currentStart, i));
445
467
  i += sep.length;
446
468
  currentStart = i;
@@ -1446,10 +1468,46 @@ export class Frond {
1446
1468
 
1447
1469
  private extractBlocks(source: string): Record<string, string> {
1448
1470
  const blocks: Record<string, string> = {};
1449
- const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
1450
- let m: RegExpExecArray | null;
1451
- while ((m = pattern.exec(source)) !== null) {
1452
- blocks[m[1]] = m[2];
1471
+ const blockOpen = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}/g;
1472
+ const blockClose = /\{%[-\s]*endblock\s*[-]?%\}/g;
1473
+
1474
+ let pos = 0;
1475
+ while (pos < source.length) {
1476
+ blockOpen.lastIndex = pos;
1477
+ const mOpen = blockOpen.exec(source);
1478
+ if (!mOpen) break;
1479
+
1480
+ const name = mOpen[1];
1481
+ const contentStart = mOpen.index + mOpen[0].length;
1482
+ let depth = 1;
1483
+ let scan = contentStart;
1484
+
1485
+ while (depth > 0 && scan < source.length) {
1486
+ blockOpen.lastIndex = scan;
1487
+ blockClose.lastIndex = scan;
1488
+ const nextOpen = blockOpen.exec(source);
1489
+ const nextClose = blockClose.exec(source);
1490
+
1491
+ if (!nextClose) break; // malformed — no matching endblock
1492
+
1493
+ if (nextOpen && nextOpen.index < nextClose.index) {
1494
+ depth++;
1495
+ scan = nextOpen.index + nextOpen[0].length;
1496
+ } else {
1497
+ depth--;
1498
+ if (depth === 0) {
1499
+ blocks[name] = source.slice(contentStart, nextClose.index);
1500
+ pos = nextClose.index + nextClose[0].length;
1501
+ break;
1502
+ }
1503
+ scan = nextClose.index + nextClose[0].length;
1504
+ }
1505
+ }
1506
+
1507
+ if (depth > 0) {
1508
+ // malformed, skip forward
1509
+ pos = contentStart;
1510
+ }
1453
1511
  }
1454
1512
  return blocks;
1455
1513
  }
@@ -1459,6 +1517,40 @@ export class Frond {
1459
1517
  context: Record<string, unknown>,
1460
1518
  childBlocks: Record<string, string>,
1461
1519
  ): string {
1520
+ // --- Multi-level extends: check if parent itself extends a grandparent ---
1521
+ const extendsMatch = parentSource.trimStart().match(/\{%[-\s]*extends\s+["'](.+?)["']\s*[-]?%\}/);
1522
+ if (extendsMatch) {
1523
+ const grandparentName = extendsMatch[1];
1524
+ const grandparentSource = this.load(grandparentName);
1525
+
1526
+ // Extract block defaults defined in the parent template
1527
+ const parentBlocks = this.extractBlocks(parentSource);
1528
+
1529
+ // Child blocks override parent blocks at the same name
1530
+ const mergedBlocks: Record<string, string> = { ...parentBlocks, ...childBlocks };
1531
+
1532
+ // Resolve nested blocks: if a block value contains {% block inner %} tags,
1533
+ // replace them with mergedBlocks values too
1534
+ const nestedBlockRe = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
1535
+ let changed = true;
1536
+ while (changed) {
1537
+ changed = false;
1538
+ for (const name of Object.keys(mergedBlocks)) {
1539
+ const resolved = mergedBlocks[name].replace(nestedBlockRe, (_m, innerName: string, innerDefault: string) => {
1540
+ return mergedBlocks[innerName] ?? innerDefault;
1541
+ });
1542
+ if (resolved !== mergedBlocks[name]) {
1543
+ mergedBlocks[name] = resolved;
1544
+ changed = true;
1545
+ }
1546
+ }
1547
+ }
1548
+
1549
+ // Recurse up the chain (handles 3+, 4+, ... levels)
1550
+ return this.renderWithBlocks(grandparentSource, context, mergedBlocks);
1551
+ }
1552
+
1553
+ // --- Leaf parent (no extends) — resolve blocks and render ---
1462
1554
  const pattern = /\{%[-\s]*block\s+(\w+)\s*[-]?%\}([\s\S]*?)\{%[-\s]*endblock\s*[-]?%\}/g;
1463
1555
  const engine = this;
1464
1556
 
@@ -4,6 +4,70 @@ import { getAdapter } from "./database.js";
4
4
  import { buildQuery, parseQueryString } from "./query.js";
5
5
  import { validate } from "./validation.js";
6
6
 
7
+ /**
8
+ * Auto-CRUD — discovers ORM models and auto-generates REST endpoints.
9
+ *
10
+ * Generated endpoints per model:
11
+ * GET /api/{table} — list with pagination, filtering, sorting
12
+ * GET /api/{table}/{id} — get single record
13
+ * POST /api/{table} — create record
14
+ * PUT /api/{table}/{id} — update record
15
+ * DELETE /api/{table}/{id} — delete record
16
+ */
17
+ export class AutoCrud {
18
+ private static registered: Map<string, DiscoveredModel> = new Map();
19
+
20
+ /**
21
+ * Register a model for auto-CRUD.
22
+ */
23
+ static register(model: DiscoveredModel, prefix: string = "/api"): void {
24
+ const tableName = model.definition.tableName;
25
+ if (!tableName) {
26
+ throw new Error(`AutoCrud: model has no tableName set.`);
27
+ }
28
+ AutoCrud.registered.set(tableName, model);
29
+ }
30
+
31
+ /**
32
+ * Discover models from the provided array and register them.
33
+ * (In Node.js, models are discovered by the server and passed in.)
34
+ */
35
+ static discover(discoveredModels: DiscoveredModel[], prefix: string = "/api"): string[] {
36
+ const names: string[] = [];
37
+ for (const model of discoveredModels) {
38
+ AutoCrud.register(model, prefix);
39
+ names.push(model.definition.tableName);
40
+ }
41
+ return names;
42
+ }
43
+
44
+ /**
45
+ * Return all registered models.
46
+ */
47
+ static models(): Map<string, DiscoveredModel> {
48
+ return new Map(AutoCrud.registered);
49
+ }
50
+
51
+ /**
52
+ * Clear all registered models (useful for testing).
53
+ */
54
+ static clear(): void {
55
+ AutoCrud.registered.clear();
56
+ }
57
+
58
+ /**
59
+ * Generate route definitions for all registered models.
60
+ */
61
+ static generateRoutes(): RouteDefinition[] {
62
+ const models = Array.from(AutoCrud.registered.values());
63
+ return generateCrudRoutes(models);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Generate CRUD route definitions for the given models.
69
+ * (Standalone function for backward compatibility.)
70
+ */
7
71
  export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[] {
8
72
  const routes: RouteDefinition[] = [];
9
73
 
@@ -38,33 +102,28 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
38
102
  },
39
103
  handler: async (req: Tina4Request, res: Tina4Response) => {
40
104
  const adapter = getAdapter();
41
- const options = parseQueryString(req.query);
42
- const { sql, countSql, params } = buildQuery(tableName, options, extraConditions);
43
105
 
44
- const countParams = params.slice(0, -2); // Remove LIMIT and OFFSET
45
- const items = adapter.query(sql, params);
46
- const [{ total }] = adapter.query<{ total: number }>(countSql, countParams);
106
+ // Parse query params for filtering / sorting / pagination
107
+ const qp = parseQueryString(req.query ?? {});
108
+ const { sql, countSql, params } = buildQuery(tableName, qp, extraConditions);
109
+
110
+ // params includes limit and offset at the end; countSql doesn't need them
111
+ const countParams = params.slice(0, -2);
112
+ const rows = adapter.query(sql, params);
47
113
 
48
- const limit = options.limit ?? 100;
49
- const page = options.page ?? 1;
50
- const offset = (page - 1) * limit;
51
- const totalPages = Math.ceil(total / limit);
114
+ const countRow = adapter.query(countSql, countParams);
115
+ const total = Number(countRow[0]?.total ?? 0);
116
+ const limit = qp.limit ?? 100;
117
+ const page = qp.page ?? 1;
52
118
 
53
119
  res.json({
54
- // Primary keys
55
- records: items,
56
- data: items,
57
- count: total,
58
- total,
59
- limit,
60
- offset,
61
- page,
62
- per_page: limit,
63
- perPage: limit,
64
- totalPages,
65
- total_pages: totalPages,
66
- // Legacy nested meta (kept for any clients that use it)
67
- meta: { total, page, limit, totalPages },
120
+ data: rows,
121
+ meta: {
122
+ total,
123
+ page,
124
+ limit,
125
+ totalPages: Math.ceil(total / limit),
126
+ },
68
127
  });
69
128
  },
70
129
  });
@@ -79,16 +138,19 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
79
138
  },
80
139
  handler: async (req: Tina4Request, res: Tina4Response) => {
81
140
  const adapter = getAdapter();
141
+
82
142
  const conditions = [`"${pkColumn}" = ?`, ...extraConditions];
83
- const items = adapter.query(
143
+ const rows = adapter.query(
84
144
  `SELECT * FROM "${tableName}" WHERE ${conditions.join(" AND ")}`,
85
145
  [req.params.id],
86
146
  );
87
- if (items.length === 0) {
147
+
148
+ if (rows.length === 0) {
88
149
  res.status(404).json({ error: "Not Found", statusCode: 404 });
89
150
  return;
90
151
  }
91
- res.json({ data: items[0] });
152
+
153
+ res.json({ data: rows[0] });
92
154
  },
93
155
  });
94
156
 
@@ -101,47 +163,39 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
101
163
  tags: [tableName],
102
164
  },
103
165
  handler: async (req: Tina4Request, res: Tina4Response) => {
104
- const body = req.body as Record<string, unknown> | undefined;
105
- if (!body || typeof body !== "object") {
106
- res.status(400).json({ error: "Request body is required", statusCode: 400 });
107
- return;
108
- }
166
+ const adapter = getAdapter();
167
+ const body = req.body as Record<string, unknown>;
109
168
 
110
- const errors = validate(body, fields, false);
169
+ // Validate against field definitions
170
+ const errors = validate(body, fields);
111
171
  if (errors.length > 0) {
112
- res.status(422).json({ error: "Validation failed", statusCode: 422, errors });
172
+ res.status(422).json({ error: "Validation failed", errors });
113
173
  return;
114
174
  }
115
175
 
116
- const adapter = getAdapter();
117
-
118
- // Filter to known fields, exclude auto-increment PKs
119
- const insertFields = Object.entries(body).filter(([key]) => {
120
- const def = fields[key];
121
- return def && !(def.primaryKey && def.autoIncrement);
122
- });
123
-
124
- // Add is_deleted = 0 for soft delete models
125
- if (softDelete) {
126
- insertFields.push(["is_deleted", 0]);
176
+ // Map JS property names to DB column names
177
+ const dbBody: Record<string, unknown> = {};
178
+ for (const [key, value] of Object.entries(body)) {
179
+ dbBody[getDbCol(key)] = value;
127
180
  }
128
181
 
129
- const columns = insertFields.map(([k]) => `"${getDbCol(k)}"`).join(", ");
130
- const placeholders = insertFields.map(() => "?").join(", ");
131
- const values = insertFields.map(([, v]) => v);
182
+ const columns = Object.keys(dbBody);
183
+ const values = Object.values(dbBody);
184
+ const placeholders = columns.map(() => "?").join(", ");
132
185
 
133
- const result = adapter.execute(
134
- `INSERT INTO "${tableName}" (${columns}) VALUES (${placeholders})`,
186
+ adapter.execute(
187
+ `INSERT INTO "${tableName}" (${columns.map((c) => `"${c}"`).join(", ")}) VALUES (${placeholders})`,
135
188
  values,
136
- ) as { lastInsertRowid?: number };
189
+ );
137
190
 
138
- const id = result.lastInsertRowid;
191
+ // Fetch the created record to include auto-generated fields (e.g. id)
192
+ const lastId = adapter.lastInsertId();
139
193
  const created = adapter.query(
140
194
  `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = ?`,
141
- [id],
195
+ [lastId],
142
196
  );
143
197
 
144
- res.status(201).json({ data: created[0] });
198
+ res.status(201).json({ data: created[0] ?? { ...body, [pkField]: lastId } });
145
199
  },
146
200
  });
147
201
 
@@ -154,21 +208,9 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
154
208
  tags: [tableName],
155
209
  },
156
210
  handler: async (req: Tina4Request, res: Tina4Response) => {
157
- const body = req.body as Record<string, unknown> | undefined;
158
- if (!body || typeof body !== "object") {
159
- res.status(400).json({ error: "Request body is required", statusCode: 400 });
160
- return;
161
- }
162
-
163
- const errors = validate(body, fields, true);
164
- if (errors.length > 0) {
165
- res.status(422).json({ error: "Validation failed", statusCode: 422, errors });
166
- return;
167
- }
168
-
169
211
  const adapter = getAdapter();
212
+ const body = req.body as Record<string, unknown>;
170
213
 
171
- // Check exists (respect soft delete)
172
214
  const conditions = [`"${pkColumn}" = ?`, ...extraConditions];
173
215
  const existing = adapter.query(
174
216
  `SELECT * FROM "${tableName}" WHERE ${conditions.join(" AND ")}`,
@@ -179,18 +221,19 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
179
221
  return;
180
222
  }
181
223
 
182
- // Filter to known fields
183
- const updateFields = Object.entries(body).filter(([key]) => fields[key] && !fields[key].primaryKey);
184
- if (updateFields.length === 0) {
185
- res.json({ data: existing[0] });
186
- return;
224
+ // Map JS property names to DB column names
225
+ const dbBody: Record<string, unknown> = {};
226
+ for (const [key, value] of Object.entries(body)) {
227
+ dbBody[getDbCol(key)] = value;
187
228
  }
188
229
 
189
- const setClause = updateFields.map(([k]) => `"${getDbCol(k)}" = ?`).join(", ");
190
- const values = [...updateFields.map(([, v]) => v), req.params.id];
230
+ const setClauses = Object.keys(dbBody)
231
+ .map((col) => `"${col}" = ?`)
232
+ .join(", ");
233
+ const values = [...Object.values(dbBody), req.params.id];
191
234
 
192
235
  adapter.execute(
193
- `UPDATE "${tableName}" SET ${setClause} WHERE "${pkColumn}" = ?`,
236
+ `UPDATE "${tableName}" SET ${setClauses} WHERE "${pkColumn}" = ?`,
194
237
  values,
195
238
  );
196
239
 
@@ -201,7 +201,7 @@ export class BaseModel {
201
201
  * @returns A QueryBuilder instance bound to this model's table and database.
202
202
  */
203
203
  static query(): QueryBuilder {
204
- return QueryBuilder.from(this.tableName, this.getDb());
204
+ return QueryBuilder.fromTable(this.tableName, this.getDb());
205
205
  }
206
206
 
207
207
  /**
@@ -122,6 +122,11 @@ export class DatabaseResult implements Iterable<Record<string, unknown>> {
122
122
  return this.records[Symbol.iterator]();
123
123
  }
124
124
 
125
+ /** Total count — cross-framework parity with Python/Ruby. */
126
+ size(): number {
127
+ return this.count;
128
+ }
129
+
125
130
  /** Number of records in this page. */
126
131
  get length(): number {
127
132
  return this.records.length;
@@ -35,7 +35,7 @@ export {
35
35
  Migration,
36
36
  } from "./migration.js";
37
37
  export type { MigrationResult, MigrationStatus } from "./migration.js";
38
- export { generateCrudRoutes } from "./autoCrud.js";
38
+ export { AutoCrud, generateCrudRoutes } from "./autoCrud.js";
39
39
  export { buildQuery, parseQueryString } from "./query.js";
40
40
  export { validate } from "./validation.js";
41
41
  export type { ValidationError } from "./validation.js";
@@ -706,8 +706,11 @@ export async function status(
706
706
  */
707
707
  export async function createMigration(
708
708
  description: string,
709
- options?: { migrationsDir?: string },
710
- ): Promise<{ upPath: string; downPath: string }> {
709
+ options?: { migrationsDir?: string; kind?: "sql" | "class" },
710
+ ): Promise<string | { upPath: string; downPath: string }> {
711
+ if (options?.kind === "class") {
712
+ return createClassMigration(description, options);
713
+ }
711
714
  const dir = resolve(options?.migrationsDir ?? "migrations");
712
715
 
713
716
  // Ensure directory exists
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Usage:
5
5
  * // Standalone
6
- * const result = QueryBuilder.from("users", db)
6
+ * const result = QueryBuilder.fromTable("users", db)
7
7
  * .select("id", "name")
8
8
  * .where("active = ?", [1])
9
9
  * .orderBy("name ASC")
@@ -49,7 +49,7 @@ export class QueryBuilder {
49
49
  * @param db - Optional database adapter.
50
50
  * @returns A new QueryBuilder instance.
51
51
  */
52
- static from(tableName: string, db?: DatabaseAdapter): QueryBuilder {
52
+ static fromTable(tableName: string, db?: DatabaseAdapter): QueryBuilder {
53
53
  return new QueryBuilder(tableName, db);
54
54
  }
55
55
 
@@ -8,9 +8,9 @@ interface OpenAPISpec {
8
8
  components?: { schemas?: Record<string, unknown> };
9
9
  }
10
10
 
11
- export function generateOpenAPISpec(
11
+ export function generate(
12
12
  routes: RouteDefinition[],
13
- models: ModelDefinition[]
13
+ models: ModelDefinition[] = []
14
14
  ): OpenAPISpec {
15
15
  const spec: OpenAPISpec = {
16
16
  openapi: "3.0.3",
@@ -1,2 +1,2 @@
1
- export { generateOpenAPISpec } from "./generator.js";
1
+ export { generate } from "./generator.js";
2
2
  export { createSwaggerRoutes } from "./ui.js";