allez-orm 1.0.9 → 1.0.11
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/allez-orm.mjs +34 -7
- package/index.d.ts +21 -29
- package/package.json +1 -1
- package/tools/allez-orm.mjs +220 -298
- package/tools/ddl-audit.mjs +24 -78
package/allez-orm.mjs
CHANGED
|
@@ -170,8 +170,7 @@ export class AllezORM {
|
|
|
170
170
|
);
|
|
171
171
|
},
|
|
172
172
|
async deleteSoft(id, ts = new Date().toISOString()) {
|
|
173
|
-
// keep naming consistent
|
|
174
|
-
// adjust if your table uses deleted_at instead
|
|
173
|
+
// keep naming consistent across projects
|
|
175
174
|
try {
|
|
176
175
|
await self.execute(`UPDATE ${table} SET deletedAt=? WHERE id=?`, [ts, id]);
|
|
177
176
|
} catch {
|
|
@@ -198,23 +197,51 @@ export class AllezORM {
|
|
|
198
197
|
|
|
199
198
|
// ---------------- schema registration ----------------
|
|
200
199
|
|
|
200
|
+
/** Soft-run an extra SQL statement; swallow known-unsupported patterns. */
|
|
201
|
+
async #tryExtra(sql) {
|
|
202
|
+
const s = (sql || "").trim();
|
|
203
|
+
if (!s) return;
|
|
204
|
+
|
|
205
|
+
// SQLite does not support adding FK constraints via ALTER TABLE.
|
|
206
|
+
const isAlterFk =
|
|
207
|
+
/^ALTER\s+TABLE\s+.+\s+ADD\s+FOREIGN\s+KEY/i.test(s);
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
if (isAlterFk) {
|
|
211
|
+
console.warn(
|
|
212
|
+
"[AllezORM] Skipping unsupported statement (SQLite):",
|
|
213
|
+
s
|
|
214
|
+
);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
await this.execute(s);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.warn("[AllezORM] extraSQL failed and was skipped:", s, err);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
201
223
|
/** @param {Schema[]} schemas */
|
|
202
224
|
async registerSchemas(schemas) {
|
|
203
225
|
const meta = await this.#currentVersions();
|
|
204
226
|
for (const s of schemas) {
|
|
205
|
-
if (!s?.table || !s?.createSQL)
|
|
206
|
-
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
227
|
+
if (!s?.table || !s?.createSQL) continue;
|
|
228
|
+
|
|
209
229
|
const exists = await this.get(
|
|
210
230
|
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
|
|
211
231
|
[s.table]
|
|
212
232
|
);
|
|
233
|
+
|
|
213
234
|
if (!exists) {
|
|
235
|
+
// Hard-fail for invalid CREATE (developer action required)
|
|
214
236
|
await this.execute(s.createSQL);
|
|
237
|
+
|
|
238
|
+
// Best-effort for side DDL (indexes/triggers/fts)
|
|
215
239
|
if (Array.isArray(s.extraSQL)) {
|
|
216
|
-
for (const x of s.extraSQL)
|
|
240
|
+
for (const x of s.extraSQL) {
|
|
241
|
+
await this.#tryExtra(x);
|
|
242
|
+
}
|
|
217
243
|
}
|
|
244
|
+
|
|
218
245
|
await this.execute(
|
|
219
246
|
`INSERT OR REPLACE INTO allez_meta(table_name,version) VALUES(?,?)`,
|
|
220
247
|
[s.table, s.version ?? 1]
|
package/index.d.ts
CHANGED
|
@@ -13,7 +13,6 @@ export interface InitOptions {
|
|
|
13
13
|
autoSaveMs?: number;
|
|
14
14
|
wasmLocateFile?(file: string): string;
|
|
15
15
|
schemas?: Schema[];
|
|
16
|
-
/** Modules that default-export a Schema (useful for tree-shaking/auto-collect). */
|
|
17
16
|
schemaModules?: Record<string, { default: Schema }>;
|
|
18
17
|
}
|
|
19
18
|
|
|
@@ -23,61 +22,54 @@ export interface TableHelper<T extends Row = Row> {
|
|
|
23
22
|
insert(obj: Partial<T>): Promise<void>;
|
|
24
23
|
upsert(obj: Partial<T>): Promise<void>;
|
|
25
24
|
update(id: any, patch: Partial<T>): Promise<void>;
|
|
26
|
-
/** Soft delete (tries `deletedAt`; falls back to `deleted_at`) */
|
|
27
25
|
deleteSoft(id: any, ts?: string): Promise<void>;
|
|
28
26
|
remove(id: any): Promise<void>;
|
|
29
|
-
findById(id: any): Promise<T |
|
|
27
|
+
findById(id: any): Promise<T | undefined>;
|
|
30
28
|
searchLike(q: string, columns: (keyof T | string)[], limit?: number): Promise<T[]>;
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
export class AllezORM {
|
|
34
32
|
constructor(SQL: any, db: any, opts: InitOptions);
|
|
35
|
-
|
|
33
|
+
|
|
34
|
+
/** Initialize (loads sql.js, restores from IndexedDB, applies schemas). */
|
|
36
35
|
static init(opts?: InitOptions): Promise<AllezORM>;
|
|
37
36
|
|
|
38
|
-
/** Persist
|
|
37
|
+
/** Persist the current database to IndexedDB immediately. */
|
|
39
38
|
saveNow(): Promise<void>;
|
|
40
39
|
|
|
41
|
-
/** Execute
|
|
40
|
+
/** Execute arbitrary SQL and auto-save (convenience, returns true). */
|
|
42
41
|
exec(sql: string, params?: any[]): Promise<boolean>;
|
|
43
|
-
|
|
42
|
+
|
|
43
|
+
/** Alias for exec. */
|
|
44
44
|
run(sql: string, params?: any[]): Promise<boolean>;
|
|
45
45
|
|
|
46
|
-
/**
|
|
46
|
+
/** Low-level execute; schedules a debounced save. */
|
|
47
47
|
execute(sql: string, params?: any[]): Promise<void>;
|
|
48
|
-
|
|
48
|
+
|
|
49
|
+
/** SELECT helper returning plain objects. */
|
|
49
50
|
query<T = Row>(sql: string, params?: any[]): Promise<T[]>;
|
|
50
|
-
/** Run a SELECT and return the first row or null. */
|
|
51
|
-
get<T = Row>(sql: string, params?: any[]): Promise<T | null>;
|
|
52
51
|
|
|
53
|
-
/**
|
|
52
|
+
/** SELECT one row (undefined if no row). */
|
|
53
|
+
get<T = Row>(sql: string, params?: any[]): Promise<T | undefined>;
|
|
54
|
+
|
|
55
|
+
/** Table-scoped helpers. */
|
|
54
56
|
table<T extends Row = Row>(table: string): TableHelper<T>;
|
|
55
57
|
|
|
56
|
-
/** Register/upgrade schemas. */
|
|
58
|
+
/** Register / upgrade schemas. */
|
|
57
59
|
registerSchemas(schemas: Schema[]): Promise<void>;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
/* Browser-friendly convenience exports (compat with Angular consumer) */
|
|
62
|
-
/* ------------------------------------------------------------------ */
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Open (or reuse) a browser DB by name and return an AllezORM instance.
|
|
66
|
-
* Equivalent to `AllezORM.init({ dbName: name, ...opts })` with caching.
|
|
67
|
-
*/
|
|
62
|
+
/** Open (or reuse) a browser DB by name. */
|
|
68
63
|
export function openBrowserDb(name: string, opts?: InitOptions): Promise<AllezORM>;
|
|
69
64
|
|
|
70
|
-
/** Alias for
|
|
65
|
+
/** Alias for openBrowserDb. */
|
|
71
66
|
export const openDb: typeof openBrowserDb;
|
|
72
67
|
|
|
73
|
-
/** Apply an array of
|
|
74
|
-
export function applySchemas(db: AllezORM, schemas
|
|
75
|
-
|
|
76
|
-
/** Run a SELECT and return rows (compat free functions). */
|
|
77
|
-
export function query<TRow = Row>(db: AllezORM, sql: string, params?: any[]): Promise<TRow[]>;
|
|
68
|
+
/** Apply an array of schemas to an opened AllezORM instance. */
|
|
69
|
+
export function applySchemas(db: AllezORM, schemas?: Schema[]): Promise<void>;
|
|
78
70
|
|
|
79
|
-
/**
|
|
71
|
+
/** Convenience helpers that operate on an AllezORM instance. */
|
|
72
|
+
export function query<T = Row>(db: AllezORM, sql: string, params?: any[]): Promise<T[]>;
|
|
80
73
|
export function exec(db: AllezORM, sql: string, params?: any[]): Promise<void>;
|
|
81
74
|
|
|
82
|
-
/** Keep a default export for ESM consumers. */
|
|
83
75
|
export default AllezORM;
|
package/package.json
CHANGED
package/tools/allez-orm.mjs
CHANGED
|
@@ -1,361 +1,283 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Allez ORM – schema generator CLI
|
|
4
|
+
*
|
|
5
|
+
* Generates a <table>.schema.js with:
|
|
6
|
+
* - CREATE TABLE with inline foreign keys: col TYPE REFERENCES target(id) [ON DELETE ...]
|
|
7
|
+
* - Optional "stamps": created_at, updated_at, deleted_at
|
|
8
|
+
* - Optional unique / not-null markers
|
|
9
|
+
* - Optional ON DELETE behavior for *all* FKs via --onDelete=
|
|
10
|
+
* - Auto index per FK column in extraSQL
|
|
11
|
+
* - Auto-create stub schemas for FK target tables if missing
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node tools/allez-orm.mjs create table <name> [fields...] [--dir=schemas_cli] [--stamps] [-f|--force] [--onDelete=cascade|restrict|setnull|noaction]
|
|
15
|
+
*
|
|
16
|
+
* Field syntax (comma or symbol sugar):
|
|
17
|
+
* name -> bare column "name TEXT"
|
|
18
|
+
* name! -> NOT NULL
|
|
19
|
+
* name:text -> explicit SQL type
|
|
20
|
+
* name:text! -> TEXT NOT NULL
|
|
21
|
+
* email:text!+ -> TEXT UNIQUE NOT NULL
|
|
22
|
+
* user_id:text->users
|
|
23
|
+
* org_id:integer->orgs
|
|
24
|
+
* slug:text,unique -> you can also use ",unique" or ",notnull"
|
|
25
|
+
*
|
|
26
|
+
* Defaults:
|
|
27
|
+
* - Adds "id INTEGER PRIMARY KEY AUTOINCREMENT" if you don't provide an "id" column yourself
|
|
28
|
+
* - Default type is TEXT when omitted
|
|
29
|
+
*/
|
|
30
|
+
|
|
3
31
|
import fs from "node:fs";
|
|
4
32
|
import path from "node:path";
|
|
5
33
|
import process from "node:process";
|
|
6
34
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
// ---- robust force detection (works across shells) ----
|
|
10
|
-
const FORCE_FROM_ENV = String(process.env.ALLEZ_FORCE || "").trim() === "1";
|
|
11
|
-
const FORCE_FROM_ARGV = process.argv.includes("--force") || process.argv.includes("-f");
|
|
12
|
-
const FORCE_EFFECTIVE = FORCE_FROM_ENV || FORCE_FROM_ARGV;
|
|
13
|
-
|
|
14
|
-
function die(msg, code = 1) { console.error(msg); process.exit(code); }
|
|
15
|
-
function help() {
|
|
35
|
+
const argv = process.argv.slice(2);
|
|
36
|
+
const usage = () => {
|
|
16
37
|
console.log(`
|
|
17
|
-
AllezORM schema generator
|
|
18
|
-
|
|
19
38
|
Usage:
|
|
20
|
-
allez-orm create table <name> [
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
name! / name+ / name^ ... -> short flags directly after name (default type TEXT)
|
|
33
|
-
name:type -> type = INTEGER|TEXT|REAL|BLOB|NUMERIC (aliases allowed)
|
|
34
|
-
name:type!+^#~ -> ! NOT NULL, + UNIQUE, ^ INDEX, # PRIMARY KEY, ~ AUTOINCREMENT
|
|
35
|
-
name:type,notnull,unique,index,pk,ai,default=<expr>
|
|
36
|
-
^col -> standalone index on col
|
|
37
|
-
fk shorthand:
|
|
38
|
-
col>table -> INTEGER REFERENCES table(id)
|
|
39
|
-
col>table(col) -> INTEGER REFERENCES table(col)
|
|
40
|
-
col:fk(table.col) -> same, leaves type as INTEGER unless you pass another
|
|
41
|
-
PowerShell-safe FK alias:
|
|
42
|
-
col->table[(col)] -> same as '>'
|
|
43
|
-
Per-field FK options via commas: ,onDelete=cascade ,onUpdate=restrict ,defer ,deferrable
|
|
39
|
+
allez-orm create table <name> [options] [fields...]
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
--dir=<outDir> Output directory (default: schemas_cli)
|
|
43
|
+
--stamps Add created_at, updated_at, deleted_at columns
|
|
44
|
+
--onDelete=<mode> ON DELETE action for *all* FKs (cascade|restrict|setnull|noaction). Default: none
|
|
45
|
+
-f, --force Overwrite existing file
|
|
46
|
+
--help Show this help
|
|
47
|
+
|
|
48
|
+
Field syntax:
|
|
49
|
+
col[:type][!][+][->target] or "col:type,unique,notnull"
|
|
50
|
+
Examples: email:text!+ user_id:text->users org_id:integer->orgs
|
|
44
51
|
`);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const TYPE_ALIASES = {
|
|
48
|
-
int: "INTEGER", integer: "INTEGER", bool: "INTEGER",
|
|
49
|
-
text: "TEXT", string: "TEXT", datetime: "TEXT", timestamp: "TEXT",
|
|
50
|
-
real: "REAL", float: "REAL",
|
|
51
|
-
number: "NUMERIC", numeric: "NUMERIC", blob: "BLOB"
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return name.split(/[_\- ]+/).map(s => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
58
|
-
}
|
|
59
|
-
function sanitizeTable(name) {
|
|
60
|
-
const ok = name.trim().toLowerCase().replace(/[^\w]/g, "_");
|
|
61
|
-
if (!ok) die("Invalid table name.");
|
|
62
|
-
return ok;
|
|
54
|
+
if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
|
|
55
|
+
usage();
|
|
56
|
+
process.exit(0);
|
|
63
57
|
}
|
|
64
58
|
|
|
65
|
-
|
|
59
|
+
const die = (m, code = 1) => { console.error(m); process.exit(code); };
|
|
60
|
+
|
|
61
|
+
function parseOptions(args) {
|
|
66
62
|
const out = {
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
dir: "schemas_cli",
|
|
64
|
+
stamps: false,
|
|
65
|
+
onDelete: null,
|
|
66
|
+
force: false,
|
|
67
|
+
cmd: null,
|
|
68
|
+
sub: null,
|
|
69
|
+
table: null,
|
|
70
|
+
fields: []
|
|
69
71
|
};
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (a
|
|
73
|
-
|
|
74
|
-
else if (a
|
|
75
|
-
|
|
76
|
-
else if (a
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
else
|
|
72
|
+
const positional = [];
|
|
73
|
+
for (const a of args) {
|
|
74
|
+
if (a.startsWith("--dir=")) {
|
|
75
|
+
out.dir = a.slice(6);
|
|
76
|
+
} else if (a === "--stamps") {
|
|
77
|
+
out.stamps = true;
|
|
78
|
+
} else if (a.startsWith("--onDelete=")) {
|
|
79
|
+
const v = a.slice(11).toLowerCase();
|
|
80
|
+
if (!["cascade","restrict","setnull","noaction"].includes(v)) {
|
|
81
|
+
die(`Invalid --onDelete value: ${v}`);
|
|
82
|
+
}
|
|
83
|
+
out.onDelete = v;
|
|
84
|
+
} else if (a === "-f" || a === "--force") {
|
|
85
|
+
out.force = true;
|
|
86
|
+
} else if (a.startsWith("-")) {
|
|
87
|
+
die(`Unknown option: ${a}`);
|
|
88
|
+
} else {
|
|
89
|
+
positional.push(a);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// pick up positional command pieces
|
|
93
|
+
out.cmd = positional[0] || null;
|
|
94
|
+
out.sub = positional[1] || null;
|
|
95
|
+
out.table = positional[2] || null;
|
|
96
|
+
out.fields = positional.slice(3);
|
|
97
|
+
|
|
98
|
+
// ✅ env var should not interfere with positional parsing
|
|
99
|
+
if (process.env.ALLEZ_FORCE === "1") {
|
|
100
|
+
out.force = true;
|
|
83
101
|
}
|
|
84
|
-
if (FORCE_EFFECTIVE) out.force = true;
|
|
85
|
-
|
|
86
|
-
if (out.onDelete && !VALID_ACTIONS.has(out.onDelete)) die("Invalid --onDelete action");
|
|
87
|
-
if (out.onUpdate && !VALID_ACTIONS.has(out.onUpdate)) die("Invalid --onUpdate action");
|
|
88
102
|
return out;
|
|
89
103
|
}
|
|
90
104
|
|
|
91
|
-
|
|
92
|
-
function parseNameTypeFlags(base) {
|
|
93
|
-
let name, rhs = "", explicitType = false;
|
|
105
|
+
const opts = parseOptions(argv);
|
|
94
106
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
rhs = ""; // default TEXT; short flags may be on name itself
|
|
101
|
-
}
|
|
107
|
+
// Commands
|
|
108
|
+
if (opts.cmd !== "create" || opts.sub !== "table" || !opts.table) {
|
|
109
|
+
usage();
|
|
110
|
+
die("Expected: create table <name> [fields...]");
|
|
111
|
+
}
|
|
102
112
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
// If no ":", short flags might be appended to name (e.g., "name!+")
|
|
107
|
-
// If ":", short flags might be appended to type token (e.g., "text!+")
|
|
108
|
-
let typeToken = rhs;
|
|
109
|
-
let shortFlagsStr = "";
|
|
110
|
-
if (rhs) {
|
|
111
|
-
const m = rhs.match(/^(.*?)([!+^#~?]+)?$/);
|
|
112
|
-
typeToken = (m?.[1] || "").trim();
|
|
113
|
-
shortFlagsStr = m?.[2] || "";
|
|
114
|
-
} else {
|
|
115
|
-
const m = name.match(/^(.*?)([!+^#~?]+)?$/);
|
|
116
|
-
name = (m?.[1] || "").trim();
|
|
117
|
-
shortFlagsStr = m?.[2] || "";
|
|
118
|
-
}
|
|
113
|
+
// Ensure dir
|
|
114
|
+
fs.mkdirSync(opts.dir, { recursive: true });
|
|
119
115
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
c === "#" ? "pk" :
|
|
125
|
-
c === "~" ? "ai" :
|
|
126
|
-
""
|
|
127
|
-
).filter(Boolean));
|
|
116
|
+
const outFile = path.join(opts.dir, `${opts.table}.schema.js`);
|
|
117
|
+
if (fs.existsSync(outFile) && !opts.force) {
|
|
118
|
+
die(`Refusing to overwrite existing file: ${outFile}\n(use -f or ALLEZ_FORCE=1)`);
|
|
119
|
+
}
|
|
128
120
|
|
|
129
|
-
|
|
130
|
-
let type = typeToken ? (TYPE_ALIASES[typeToken.toLowerCase()] ?? typeToken.toUpperCase()) : "";
|
|
121
|
+
// ---- Field parsing ---------------------------------------------------------
|
|
131
122
|
|
|
132
|
-
|
|
133
|
-
|
|
123
|
+
function parseFieldToken(tok) {
|
|
124
|
+
// Accept "col[:type][!][+][->target]" OR "col:type,unique,notnull"
|
|
125
|
+
const ret = { name: "", type: "TEXT", notnull: false, unique: false, fk: null };
|
|
126
|
+
if (!tok) return null;
|
|
134
127
|
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
&& !specRaw.includes("->")) {
|
|
143
|
-
return { kind: "indexOnly", name: specRaw.slice(1) };
|
|
128
|
+
// Split on "," for attribute list
|
|
129
|
+
let main = tok;
|
|
130
|
+
let flags = [];
|
|
131
|
+
if (tok.includes(",")) {
|
|
132
|
+
const [lhs, ...rhs] = tok.split(",");
|
|
133
|
+
main = lhs;
|
|
134
|
+
flags = rhs.map(s => s.trim().toLowerCase());
|
|
144
135
|
}
|
|
145
136
|
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const m = rhs.match(/^([a-zA-Z0-9_]+)(?:\(([a-zA-Z0-9_]+)\))?$/);
|
|
151
|
-
if (!m) die(`Bad FK ref '${specRaw}'`);
|
|
152
|
-
const table = sanitizeTable(m[1]);
|
|
153
|
-
const refCol = m[2] || "id";
|
|
154
|
-
|
|
155
|
-
// default FK storage type is INTEGER unless caller annotated a type
|
|
156
|
-
const resolvedType = explicitType ? (type || "TEXT") : "INTEGER";
|
|
157
|
-
const finalType = TYPE_ALIASES[(resolvedType || "").toLowerCase()] ?? (resolvedType || "TEXT");
|
|
158
|
-
|
|
159
|
-
return {
|
|
160
|
-
kind: "field",
|
|
161
|
-
name,
|
|
162
|
-
type: finalType,
|
|
163
|
-
flags,
|
|
164
|
-
fk: { table, col: refCol, opts: [] },
|
|
165
|
-
wantsIndex: flags.has("index")
|
|
166
|
-
};
|
|
167
|
-
}
|
|
137
|
+
// name : type -> target
|
|
138
|
+
let name = main;
|
|
139
|
+
let type = null;
|
|
140
|
+
let fkTarget = null;
|
|
168
141
|
|
|
169
|
-
//
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
let fk = null;
|
|
176
|
-
const typeLower = (type || "").toLowerCase();
|
|
177
|
-
if (typeLower.startsWith("fk(") && typeLower.endsWith(")")) {
|
|
178
|
-
const inside = type.slice(3, -1);
|
|
179
|
-
const [t, c = "id"] = inside.split(".");
|
|
180
|
-
fk = { table: sanitizeTable(t), col: c, opts: [] };
|
|
181
|
-
type = "INTEGER"; // storage type for fk()
|
|
142
|
+
// ->target
|
|
143
|
+
const fkIdx = main.indexOf("->");
|
|
144
|
+
if (fkIdx >= 0) {
|
|
145
|
+
fkTarget = main.slice(fkIdx + 2).trim();
|
|
146
|
+
name = main.slice(0, fkIdx);
|
|
182
147
|
}
|
|
183
148
|
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (!key) continue;
|
|
192
|
-
if (["notnull", "nn", "!"].includes(key)) flags.add("notnull");
|
|
193
|
-
else if (["unique", "u", "+"].includes(key)) flags.add("unique");
|
|
194
|
-
else if (["index", "idx", "^"].includes(key)) flags.add("index");
|
|
195
|
-
else if (["pk", "primary", "#"].includes(key)) flags.add("pk");
|
|
196
|
-
else if (["ai", "autoincrement", "~"].includes(key)) flags.add("ai");
|
|
197
|
-
else if (["default", "def", "="].includes(key)) flags.add(`default=${v}`);
|
|
198
|
-
else if (["ondelete", "on_delete"].includes(key)) fk?.opts.push(`ON DELETE ${v.toUpperCase()}`);
|
|
199
|
-
else if (["onupdate", "on_update"].includes(key)) fk?.opts.push(`ON UPDATE ${v.toUpperCase()}`);
|
|
200
|
-
else if (["defer", "deferrable"].includes(key)) fk?.opts.push(`DEFERRABLE INITIALLY DEFERRED`);
|
|
201
|
-
else die(`Unknown flag '${key}' in '${specRaw}'`);
|
|
149
|
+
// :type
|
|
150
|
+
const typeIdx = name.indexOf(":");
|
|
151
|
+
if (typeIdx >= 0) {
|
|
152
|
+
type = name.slice(typeIdx + 1).trim(); // may contain !/+
|
|
153
|
+
name = name.slice(0, typeIdx).trim();
|
|
154
|
+
} else {
|
|
155
|
+
type = null;
|
|
202
156
|
}
|
|
203
157
|
|
|
204
|
-
|
|
205
|
-
|
|
158
|
+
// Collect flags from BOTH the name token and the type token
|
|
159
|
+
const nameHasBang = /!/.test(name);
|
|
160
|
+
const nameHasPlus = /\+/.test(name);
|
|
161
|
+
const typeHasBang = type ? /!/.test(type) : false;
|
|
162
|
+
const typeHasPlus = type ? /\+/.test(type) : false;
|
|
206
163
|
|
|
207
|
-
|
|
208
|
-
|
|
164
|
+
if (nameHasBang || typeHasBang) ret.notnull = true;
|
|
165
|
+
if (nameHasPlus || typeHasPlus) ret.unique = true;
|
|
209
166
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (f.flags?.has("unique")) parts.push("UNIQUE");
|
|
216
|
-
if (f.flags?.has("notnull")) parts.push("NOT NULL");
|
|
217
|
-
|
|
218
|
-
// FK inline constraint
|
|
219
|
-
const fk = f.fk;
|
|
220
|
-
if (fk) {
|
|
221
|
-
const segs = [`REFERENCES ${fk.table}(${fk.col})`];
|
|
222
|
-
const scoped = [];
|
|
223
|
-
const gDel = globalFK?.onDelete ? `ON DELETE ${globalFK.onDelete.toUpperCase()}` : null;
|
|
224
|
-
const gUpd = globalFK?.onUpdate ? `ON UPDATE ${globalFK.onUpdate.toUpperCase()}` : null;
|
|
225
|
-
if (fk.opts?.length) scoped.push(...fk.opts);
|
|
226
|
-
else { if (gDel) scoped.push(gDel); if (gUpd) scoped.push(gUpd); }
|
|
227
|
-
parts.push(segs.join(" "));
|
|
228
|
-
if (scoped.length) parts.push(scoped.join(" "));
|
|
167
|
+
// Clean trailing !/+ off name and type segments
|
|
168
|
+
name = name.replace(/[!+]+$/,"").trim();
|
|
169
|
+
if (type) {
|
|
170
|
+
type = type.replace(/[!+]+$/,"").trim();
|
|
171
|
+
ret.type = type.toUpperCase();
|
|
229
172
|
}
|
|
230
173
|
|
|
231
|
-
|
|
232
|
-
|
|
174
|
+
if (fkTarget) ret.fk = { table: fkTarget, column: "id" };
|
|
175
|
+
|
|
176
|
+
// also allow ",unique,notnull"
|
|
177
|
+
for (const f of flags) {
|
|
178
|
+
if (f === "unique") ret.unique = true;
|
|
179
|
+
if (f === "notnull") ret.notnull = true;
|
|
180
|
+
}
|
|
233
181
|
|
|
234
|
-
|
|
182
|
+
ret.name = name;
|
|
183
|
+
return ret;
|
|
235
184
|
}
|
|
236
185
|
|
|
237
|
-
|
|
238
|
-
async function main() {
|
|
239
|
-
const cfg = parseArgs(process.argv.slice(2));
|
|
240
|
-
const raw = cfg.nonFlags;
|
|
241
|
-
const a = raw.length && raw[0] === "--" ? raw.slice(1) : raw; // belt & suspenders for stray '--'
|
|
186
|
+
const fields = opts.fields.map(parseFieldToken).filter(Boolean);
|
|
242
187
|
|
|
243
|
-
|
|
244
|
-
|
|
188
|
+
// Ensure an id column if not provided
|
|
189
|
+
const hasId = fields.some(f => f.name === "id");
|
|
190
|
+
if (!hasId) {
|
|
191
|
+
fields.unshift({ name: "id", type: "INTEGER", notnull: true, unique: false, fk: null, pk: true });
|
|
192
|
+
}
|
|
245
193
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
194
|
+
// Stamps
|
|
195
|
+
if (opts.stamps) {
|
|
196
|
+
fields.push(
|
|
197
|
+
{ name: "created_at", type: "TEXT", notnull: true },
|
|
198
|
+
{ name: "updated_at", type: "TEXT", notnull: true },
|
|
199
|
+
{ name: "deleted_at", type: "TEXT", notnull: false }
|
|
200
|
+
);
|
|
201
|
+
}
|
|
249
202
|
|
|
250
|
-
|
|
203
|
+
// ---- SQL assembly ----------------------------------------------------------
|
|
251
204
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (!hasUserPK) cols.push({ name: "id", type: "INTEGER", flags: new Set(["pk", "ai"]) });
|
|
205
|
+
function sqlForColumn(f) {
|
|
206
|
+
if (f.pk) return `id INTEGER PRIMARY KEY AUTOINCREMENT`;
|
|
207
|
+
let s = `${f.name} ${f.type}`;
|
|
256
208
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
cols.push(f);
|
|
261
|
-
if (f.fk) fkRefs.push({ table: f.fk.table, col: f.fk.col });
|
|
262
|
-
}
|
|
209
|
+
// Match test ordering: UNIQUE first, then NOT NULL
|
|
210
|
+
if (f.unique) s += ` UNIQUE`;
|
|
211
|
+
if (f.notnull) s += ` NOT NULL`;
|
|
263
212
|
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// Compose DDL
|
|
271
|
-
const ddl =
|
|
272
|
-
`CREATE TABLE IF NOT EXISTS ${table} (
|
|
273
|
-
${cols.map(c => buildColumnSQL(c, { onDelete: cfg.onDelete, onUpdate: cfg.onUpdate })).join(",\n ")}
|
|
274
|
-
);`;
|
|
275
|
-
|
|
276
|
-
// Extra indexes: any explicit ^ plus FK columns (unless --no-fk-index)
|
|
277
|
-
const extraIdx = new Set();
|
|
278
|
-
for (const f of specs) {
|
|
279
|
-
if (f.kind === "indexOnly") {
|
|
280
|
-
extraIdx.add(`CREATE INDEX IF NOT EXISTS idx_${table}_${f.name} ON ${table}(${f.name});`);
|
|
281
|
-
} else {
|
|
282
|
-
if (f.wantsIndex) extraIdx.add(`CREATE INDEX IF NOT EXISTS idx_${table}_${f.name} ON ${table}(${f.name});`);
|
|
283
|
-
if (f.fk && !cfg.noFkIndex) extraIdx.add(`CREATE INDEX IF NOT EXISTS idx_${table}_${f.name}_fk ON ${table}(${f.name});`);
|
|
213
|
+
if (f.fk) {
|
|
214
|
+
s += ` REFERENCES ${f.fk.table}(${f.fk.column})`;
|
|
215
|
+
if (opts.onDelete) {
|
|
216
|
+
const map = { cascade: "CASCADE", restrict: "RESTRICT", setnull: "SET NULL", noaction: "NO ACTION" };
|
|
217
|
+
s += ` ON DELETE ${map[opts.onDelete]}`;
|
|
284
218
|
}
|
|
285
219
|
}
|
|
220
|
+
return s;
|
|
221
|
+
}
|
|
286
222
|
|
|
287
|
-
|
|
288
|
-
`// ./schemas/${table}.schema.js
|
|
289
|
-
const ${pascal} = {
|
|
290
|
-
table: "${table}",
|
|
291
|
-
version: ${cfg.version},
|
|
223
|
+
const columnLines = fields.map(sqlForColumn);
|
|
292
224
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
225
|
+
// Build extraSQL (indexes for FK columns) — emit with BACKTICKS to satisfy tests
|
|
226
|
+
const extraSQL = [];
|
|
227
|
+
for (const f of fields) {
|
|
228
|
+
if (f.fk) {
|
|
229
|
+
extraSQL.push(
|
|
230
|
+
`\`CREATE INDEX IF NOT EXISTS idx_${opts.table}_${f.name}_fk ON ${opts.table}(${f.name});\``
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
296
234
|
|
|
235
|
+
// Compose module text
|
|
236
|
+
const moduleText = `// ${opts.table}.schema.js (generated by tools/allez-orm.mjs)
|
|
237
|
+
const ${camel(opts.table)}Schema = {
|
|
238
|
+
table: "${opts.table}",
|
|
239
|
+
version: 1,
|
|
240
|
+
createSQL: \`
|
|
241
|
+
CREATE TABLE IF NOT EXISTS ${opts.table} (
|
|
242
|
+
${columnLines.join(",\n ")}
|
|
243
|
+
);\`,
|
|
297
244
|
extraSQL: [
|
|
298
|
-
${
|
|
245
|
+
${extraSQL.join("\n ")}
|
|
299
246
|
]
|
|
300
247
|
};
|
|
301
|
-
|
|
302
|
-
export default ${pascal};
|
|
248
|
+
export default ${camel(opts.table)}Schema;
|
|
303
249
|
`;
|
|
304
250
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
import type { Schema } from "../allez-orm";
|
|
308
|
-
const ${pascal}: Schema = {
|
|
309
|
-
table: "${table}",
|
|
310
|
-
version: ${cfg.version},
|
|
251
|
+
fs.writeFileSync(outFile, moduleText, "utf8");
|
|
252
|
+
console.log(`Wrote ${outFile}`);
|
|
311
253
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
extraSQL: [
|
|
317
|
-
${[...extraIdx].map(s => ` \`${s}\``).join("\n")}
|
|
318
|
-
]
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
export default ${pascal};
|
|
322
|
-
`;
|
|
254
|
+
// ---- Auto-create stub schemas for FK targets (if missing) ------------------
|
|
255
|
+
const fkTargets = Array.from(new Set(fields.filter(f => f.fk).map(f => f.fk.table)))
|
|
256
|
+
.filter(t => t && t !== opts.table);
|
|
323
257
|
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
if (!fs.existsSync(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (fs.existsSync(outFile) && !cfg.force) die(`Refusing to overwrite: ${path.relative(CWD, outFile)} (use --force)`);
|
|
331
|
-
fs.writeFileSync(outFile, cfg.asTS ? fileTS : fileJS, "utf8");
|
|
332
|
-
console.log(`✔ Created ${path.relative(CWD, outFile)}`);
|
|
333
|
-
|
|
334
|
-
// Ensure referenced schemas exist (minimal ones)
|
|
335
|
-
if (cfg.ensureRefs && fkRefs.length) {
|
|
336
|
-
for (const r of fkRefs) {
|
|
337
|
-
const refPathJS = path.join(dir, `${r.table}.schema.js`);
|
|
338
|
-
const refPathTS = path.join(dir, `${r.table}.schema.ts`);
|
|
339
|
-
if (fs.existsSync(refPathJS) || fs.existsSync(refPathTS)) continue;
|
|
340
|
-
const pas = kebabToPascal(r.table) + "Schema";
|
|
341
|
-
const stub =
|
|
342
|
-
`// ./schemas/${r.table}.schema.js (auto-created stub for FK target)
|
|
343
|
-
const ${pas} = {
|
|
344
|
-
table: "${r.table}",
|
|
258
|
+
for (const t of fkTargets) {
|
|
259
|
+
const stubPath = path.join(opts.dir, `${t}.schema.js`);
|
|
260
|
+
if (!fs.existsSync(stubPath)) {
|
|
261
|
+
const stub = `// ${t}.schema.js (generated by tools/allez-orm.mjs - stub for FK target)
|
|
262
|
+
const ${camel(t)}Schema = {
|
|
263
|
+
table: "${t}",
|
|
345
264
|
version: 1,
|
|
346
265
|
createSQL: \`
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
266
|
+
CREATE TABLE IF NOT EXISTS ${t} (
|
|
267
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
268
|
+
);\`,
|
|
269
|
+
extraSQL: [
|
|
270
|
+
|
|
271
|
+
]
|
|
352
272
|
};
|
|
353
|
-
export default ${
|
|
273
|
+
export default ${camel(t)}Schema;
|
|
354
274
|
`;
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
275
|
+
fs.writeFileSync(stubPath, stub, "utf8");
|
|
276
|
+
console.log(`Wrote stub ${stubPath}`);
|
|
358
277
|
}
|
|
359
278
|
}
|
|
360
279
|
|
|
361
|
-
|
|
280
|
+
process.exit(0);
|
|
281
|
+
|
|
282
|
+
// ---- helpers ---------------------------------------------------------------
|
|
283
|
+
function camel(s){return s.replace(/[-_](.)/g,(_,c)=>c.toUpperCase());}
|
package/tools/ddl-audit.mjs
CHANGED
|
@@ -1,84 +1,30 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
},
|
|
19
|
-
];
|
|
20
|
-
|
|
21
|
-
function getSqlBlocks(source) {
|
|
22
|
-
// Grab all backticked segments; our schemas put SQL in template literals.
|
|
23
|
-
const blocks = [];
|
|
24
|
-
const re = /`([\s\S]*?)`/g;
|
|
25
|
-
let m;
|
|
26
|
-
while ((m = re.exec(source))) {
|
|
27
|
-
blocks.push({ text: m[1], start: m.index + 1 }); // +1 to point inside the backtick
|
|
1
|
+
/**
|
|
2
|
+
* Simple DDL auditor for Allez ORM schemas.
|
|
3
|
+
* - Detects forbidden patterns for SQLite (e.g., ALTER TABLE ... ADD FOREIGN KEY ...)
|
|
4
|
+
* - Suggests inline FK form
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function auditCreateSQL(createSQL = "") {
|
|
8
|
+
const problems = [];
|
|
9
|
+
|
|
10
|
+
const badFk = /\bALTER\s+TABLE\b[^;]+ADD\s+FOREIGN\s+KEY/i;
|
|
11
|
+
if (badFk.test(createSQL)) {
|
|
12
|
+
problems.push({
|
|
13
|
+
rule: "sqlite.inline_fk",
|
|
14
|
+
message:
|
|
15
|
+
"SQLite does not support `ALTER TABLE ... ADD FOREIGN KEY`. Put `REFERENCES target(id)` on the column inside CREATE TABLE.",
|
|
16
|
+
fix: "Move the FK to the column definition: col TYPE REFERENCES other(id) [ON DELETE ...]"
|
|
17
|
+
});
|
|
28
18
|
}
|
|
29
|
-
return blocks;
|
|
30
|
-
}
|
|
31
19
|
|
|
32
|
-
|
|
33
|
-
// line/col are 1-based
|
|
34
|
-
let line = 1, col = 1;
|
|
35
|
-
for (let i = 0; i < idx; i++) {
|
|
36
|
-
if (text[i] === "\n") { line++; col = 1; }
|
|
37
|
-
else { col++; }
|
|
38
|
-
}
|
|
39
|
-
return { line, col };
|
|
20
|
+
return problems;
|
|
40
21
|
}
|
|
41
22
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
for (const f of files) {
|
|
48
|
-
const full = path.join(dir, f);
|
|
49
|
-
const src = fs.readFileSync(full, "utf8");
|
|
50
|
-
const blocks = getSqlBlocks(src);
|
|
51
|
-
if (!blocks.length) continue;
|
|
52
|
-
|
|
53
|
-
let fileIssues = 0;
|
|
54
|
-
for (const { text, start } of blocks) {
|
|
55
|
-
for (const rule of BAD) {
|
|
56
|
-
rule.rx.lastIndex = 0; // reset global regex
|
|
57
|
-
let m;
|
|
58
|
-
while ((m = rule.rx.exec(text))) {
|
|
59
|
-
const hitIdx = start + m.index; // index in whole file (unused except for future)
|
|
60
|
-
const { line, col } = indexToLineCol(text, m.index);
|
|
61
|
-
const ctxStart = Math.max(0, m.index - 40);
|
|
62
|
-
const ctxEnd = Math.min(text.length, m.index + m[0].length + 40);
|
|
63
|
-
const snippet = text.slice(ctxStart, ctxEnd).replace(/\n/g, "↵");
|
|
64
|
-
if (fileIssues === 0) {
|
|
65
|
-
console.log(`\n✖ ${full}`);
|
|
66
|
-
}
|
|
67
|
-
console.log(
|
|
68
|
-
` • ${rule.name} at SQL ${line}:${col} ${rule.hint}\n` +
|
|
69
|
-
` …${snippet}…`
|
|
70
|
-
);
|
|
71
|
-
fileIssues++;
|
|
72
|
-
totalIssues++;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
23
|
+
export function assertClean(createSQL = "") {
|
|
24
|
+
const issues = auditCreateSQL(createSQL);
|
|
25
|
+
if (issues.length) {
|
|
26
|
+
const msg = issues.map(i => `• ${i.message}\n → ${i.fix}`).join("\n");
|
|
27
|
+
throw new Error(`DDL audit failed:\n${msg}`);
|
|
76
28
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (totalIssues === 0) {
|
|
80
|
-
console.log("✓ SQL audit: no offending tokens found.");
|
|
81
|
-
} else {
|
|
82
|
-
console.error(`\nFound ${totalIssues} offending token(s).`);
|
|
83
|
-
process.exit(1);
|
|
29
|
+
return true;
|
|
84
30
|
}
|