allez-orm 1.0.10 → 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 +33 -5
- package/index.d.ts +28 -4
- package/package.json +1 -1
- package/tools/allez-orm.mjs +220 -298
- package/tools/ddl-audit.mjs +24 -78
package/allez-orm.mjs
CHANGED
|
@@ -197,23 +197,51 @@ export class AllezORM {
|
|
|
197
197
|
|
|
198
198
|
// ---------------- schema registration ----------------
|
|
199
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
|
+
|
|
200
223
|
/** @param {Schema[]} schemas */
|
|
201
224
|
async registerSchemas(schemas) {
|
|
202
225
|
const meta = await this.#currentVersions();
|
|
203
226
|
for (const s of schemas) {
|
|
204
|
-
if (!s?.table || !s?.createSQL)
|
|
205
|
-
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
227
|
+
if (!s?.table || !s?.createSQL) continue;
|
|
228
|
+
|
|
208
229
|
const exists = await this.get(
|
|
209
230
|
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
|
|
210
231
|
[s.table]
|
|
211
232
|
);
|
|
233
|
+
|
|
212
234
|
if (!exists) {
|
|
235
|
+
// Hard-fail for invalid CREATE (developer action required)
|
|
213
236
|
await this.execute(s.createSQL);
|
|
237
|
+
|
|
238
|
+
// Best-effort for side DDL (indexes/triggers/fts)
|
|
214
239
|
if (Array.isArray(s.extraSQL)) {
|
|
215
|
-
for (const x of s.extraSQL)
|
|
240
|
+
for (const x of s.extraSQL) {
|
|
241
|
+
await this.#tryExtra(x);
|
|
242
|
+
}
|
|
216
243
|
}
|
|
244
|
+
|
|
217
245
|
await this.execute(
|
|
218
246
|
`INSERT OR REPLACE INTO allez_meta(table_name,version) VALUES(?,?)`,
|
|
219
247
|
[s.table, s.version ?? 1]
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// index.d.ts
|
|
2
|
+
|
|
2
3
|
export interface Schema {
|
|
3
4
|
table: string;
|
|
4
5
|
createSQL: string;
|
|
@@ -21,30 +22,53 @@ export interface TableHelper<T extends Row = Row> {
|
|
|
21
22
|
insert(obj: Partial<T>): Promise<void>;
|
|
22
23
|
upsert(obj: Partial<T>): Promise<void>;
|
|
23
24
|
update(id: any, patch: Partial<T>): Promise<void>;
|
|
24
|
-
/** Soft delete; implementation will try `deletedAt` then `deleted_at`. */
|
|
25
25
|
deleteSoft(id: any, ts?: string): Promise<void>;
|
|
26
26
|
remove(id: any): Promise<void>;
|
|
27
|
-
findById(id: any): Promise<T |
|
|
27
|
+
findById(id: any): Promise<T | undefined>;
|
|
28
28
|
searchLike(q: string, columns: (keyof T | string)[], limit?: number): Promise<T[]>;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export class AllezORM {
|
|
32
32
|
constructor(SQL: any, db: any, opts: InitOptions);
|
|
33
|
+
|
|
34
|
+
/** Initialize (loads sql.js, restores from IndexedDB, applies schemas). */
|
|
33
35
|
static init(opts?: InitOptions): Promise<AllezORM>;
|
|
36
|
+
|
|
37
|
+
/** Persist the current database to IndexedDB immediately. */
|
|
34
38
|
saveNow(): Promise<void>;
|
|
39
|
+
|
|
40
|
+
/** Execute arbitrary SQL and auto-save (convenience, returns true). */
|
|
35
41
|
exec(sql: string, params?: any[]): Promise<boolean>;
|
|
42
|
+
|
|
43
|
+
/** Alias for exec. */
|
|
36
44
|
run(sql: string, params?: any[]): Promise<boolean>;
|
|
45
|
+
|
|
46
|
+
/** Low-level execute; schedules a debounced save. */
|
|
37
47
|
execute(sql: string, params?: any[]): Promise<void>;
|
|
48
|
+
|
|
49
|
+
/** SELECT helper returning plain objects. */
|
|
38
50
|
query<T = Row>(sql: string, params?: any[]): Promise<T[]>;
|
|
39
|
-
|
|
51
|
+
|
|
52
|
+
/** SELECT one row (undefined if no row). */
|
|
53
|
+
get<T = Row>(sql: string, params?: any[]): Promise<T | undefined>;
|
|
54
|
+
|
|
55
|
+
/** Table-scoped helpers. */
|
|
40
56
|
table<T extends Row = Row>(table: string): TableHelper<T>;
|
|
57
|
+
|
|
58
|
+
/** Register / upgrade schemas. */
|
|
41
59
|
registerSchemas(schemas: Schema[]): Promise<void>;
|
|
42
60
|
}
|
|
43
61
|
|
|
44
|
-
/**
|
|
62
|
+
/** Open (or reuse) a browser DB by name. */
|
|
45
63
|
export function openBrowserDb(name: string, opts?: InitOptions): Promise<AllezORM>;
|
|
64
|
+
|
|
65
|
+
/** Alias for openBrowserDb. */
|
|
46
66
|
export const openDb: typeof openBrowserDb;
|
|
67
|
+
|
|
68
|
+
/** Apply an array of schemas to an opened AllezORM instance. */
|
|
47
69
|
export function applySchemas(db: AllezORM, schemas?: Schema[]): Promise<void>;
|
|
70
|
+
|
|
71
|
+
/** Convenience helpers that operate on an AllezORM instance. */
|
|
48
72
|
export function query<T = Row>(db: AllezORM, sql: string, params?: any[]): Promise<T[]>;
|
|
49
73
|
export function exec(db: AllezORM, sql: string, params?: any[]): Promise<void>;
|
|
50
74
|
|
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
|
}
|