@fougere/adapter-sql 0.2.0-alpha.2 → 0.4.0-alpha.0
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/README.md +1 -1
- package/dist/crud.d.ts +14 -3
- package/dist/crud.d.ts.map +1 -1
- package/dist/crud.js +33 -4
- package/dist/crud.js.map +1 -1
- package/dist/ddl.d.ts.map +1 -1
- package/dist/ddl.js +2 -2
- package/dist/ddl.js.map +1 -1
- package/dist/dialect.d.ts +20 -0
- package/dist/dialect.d.ts.map +1 -1
- package/dist/dialect.js +27 -1
- package/dist/dialect.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +2 -2
- package/dist/diff.js.map +1 -1
- package/dist/fields.d.ts +22 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +2 -0
- package/dist/fields.js.map +1 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/setup.d.ts +18 -10
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +10 -23
- package/dist/setup.js.map +1 -1
- package/dist/sqlite.d.ts +12 -0
- package/dist/sqlite.d.ts.map +1 -0
- package/dist/sqlite.js +34 -0
- package/dist/sqlite.js.map +1 -0
- package/dist/step.d.ts +78 -0
- package/dist/step.d.ts.map +1 -0
- package/dist/step.js +233 -0
- package/dist/step.js.map +1 -0
- package/dist/table.d.ts +19 -10
- package/dist/table.d.ts.map +1 -1
- package/dist/table.js +36 -30
- package/dist/table.js.map +1 -1
- package/package.json +11 -4
- package/src/check.ts +76 -0
- package/src/crud.ts +570 -0
- package/src/ddl.ts +242 -0
- package/src/dialect.ts +204 -0
- package/src/diff.ts +196 -0
- package/src/fields.ts +24 -0
- package/src/index.ts +40 -0
- package/src/setup.ts +63 -0
- package/src/sqlite.ts +43 -0
- package/src/step.ts +287 -0
- package/src/table.ts +447 -0
- package/src/values.ts +105 -0
package/src/ddl.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DDL — the table description, rendered as SQL.
|
|
3
|
+
*
|
|
4
|
+
* Kysely's schema builder is what makes this dialect-agnostic: it owns the
|
|
5
|
+
* identifier quoting and the per-engine syntax, so this module only decides
|
|
6
|
+
* *what* to emit. Compilation needs no connection — a `DummyDriver` paired with
|
|
7
|
+
* a real query compiler renders the statement for any engine, which is why the
|
|
8
|
+
* whole surface is testable without a database.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
Kysely,
|
|
12
|
+
DummyDriver,
|
|
13
|
+
sql,
|
|
14
|
+
SqliteAdapter, SqliteQueryCompiler, SqliteIntrospector,
|
|
15
|
+
PostgresAdapter, PostgresQueryCompiler, PostgresIntrospector,
|
|
16
|
+
MysqlAdapter, MysqlQueryCompiler, MysqlIntrospector,
|
|
17
|
+
MssqlAdapter, MssqlQueryCompiler, MssqlIntrospector,
|
|
18
|
+
} from 'kysely';
|
|
19
|
+
import {
|
|
20
|
+
isKeyed,
|
|
21
|
+
orderTables,
|
|
22
|
+
toTableName,
|
|
23
|
+
toTables,
|
|
24
|
+
type AppLike,
|
|
25
|
+
type ColumnDef,
|
|
26
|
+
type TableDef,
|
|
27
|
+
} from './table.js';
|
|
28
|
+
import { columnTypeFor, resolveDialect, type DialectName } from './dialect.js';
|
|
29
|
+
import { checkFor } from './check.js';
|
|
30
|
+
|
|
31
|
+
// ─── Compile-only engines ──────────────────────────
|
|
32
|
+
|
|
33
|
+
const parts = {
|
|
34
|
+
sqlite: [SqliteAdapter, SqliteQueryCompiler, SqliteIntrospector],
|
|
35
|
+
pg: [PostgresAdapter, PostgresQueryCompiler, PostgresIntrospector],
|
|
36
|
+
mysql: [MysqlAdapter, MysqlQueryCompiler, MysqlIntrospector],
|
|
37
|
+
mssql: [MssqlAdapter, MssqlQueryCompiler, MssqlIntrospector],
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
const engines = new Map<DialectName, Kysely<any>>();
|
|
41
|
+
|
|
42
|
+
/** A Kysely bound to a dialect's compiler but to no connection — renders SQL only. */
|
|
43
|
+
export function compiler(name: DialectName): Kysely<any> {
|
|
44
|
+
const cached = engines.get(name);
|
|
45
|
+
if (cached) return cached;
|
|
46
|
+
const [Adapter, QueryCompiler, Introspector] = parts[name] as any;
|
|
47
|
+
const engine = new Kysely<any>({
|
|
48
|
+
dialect: {
|
|
49
|
+
createAdapter: () => new Adapter(),
|
|
50
|
+
createDriver: () => new DummyDriver(),
|
|
51
|
+
createIntrospector: (db: Kysely<any>) => new Introspector(db),
|
|
52
|
+
createQueryCompiler: () => new QueryCompiler(),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
engines.set(name, engine);
|
|
56
|
+
return engine;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── CREATE TABLE ──────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render `CREATE TABLE` for one described table.
|
|
63
|
+
*
|
|
64
|
+
* `IF NOT EXISTS` is emitted everywhere it exists — SQL Server has no such
|
|
65
|
+
* clause, so there the statement is bare and the caller must not replay it
|
|
66
|
+
* blindly (the diff pass, once it lands, answers that properly).
|
|
67
|
+
*
|
|
68
|
+
* `skipReferences` names the columns whose FK is rendered WITHOUT the inline
|
|
69
|
+
* `references()` — the column itself still gets created; `orderTables` sends a
|
|
70
|
+
* column here when its target is part of a cycle, so the constraint reaches the
|
|
71
|
+
* table separately, once every table involved exists (`addForeignKeyConstraintSQL`).
|
|
72
|
+
*/
|
|
73
|
+
export function createTableSQL(
|
|
74
|
+
table: TableDef,
|
|
75
|
+
dialectName: DialectName,
|
|
76
|
+
options?: { skipReferences?: Set<string> },
|
|
77
|
+
): string {
|
|
78
|
+
const dialect = resolveDialect(dialectName);
|
|
79
|
+
const composite = table.compositePrimary.length > 0;
|
|
80
|
+
const skip = options?.skipReferences;
|
|
81
|
+
let builder = compiler(dialectName).schema.createTable(table.name);
|
|
82
|
+
if (dialectName !== 'mssql') builder = builder.ifNotExists();
|
|
83
|
+
|
|
84
|
+
for (const column of table.columns) {
|
|
85
|
+
const type = columnTypeFor(dialect, column, isKeyed(table, column));
|
|
86
|
+
builder = builder.addColumn(column.name, sql.raw(type) as any, (col) => {
|
|
87
|
+
let built = col;
|
|
88
|
+
// A simple key is inline; a composite one becomes a table constraint.
|
|
89
|
+
if (column.primary && !composite) built = built.primaryKey();
|
|
90
|
+
if (!column.nullable) built = built.notNull();
|
|
91
|
+
if (column.default !== undefined) built = built.defaultTo(column.default);
|
|
92
|
+
// Uniqueness is the storage's to enforce: no shape can express it, since judging
|
|
93
|
+
// one value never sees the other rows.
|
|
94
|
+
if (column.unique) built = built.unique();
|
|
95
|
+
if (column.references && !skip?.has(column.name)) {
|
|
96
|
+
built = built.references(`${column.references.table}.${column.references.column}`);
|
|
97
|
+
if (column.references.onDelete) built = built.onDelete(column.references.onDelete);
|
|
98
|
+
}
|
|
99
|
+
return built;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (composite) {
|
|
104
|
+
builder = builder.addPrimaryKeyConstraint(`${table.name}_pk`, table.compositePrimary as any);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The pair the entity declared. Named after its columns so a second group on the
|
|
108
|
+
// same table cannot collide, and so a migration can recognize it later.
|
|
109
|
+
for (const group of table.uniqueGroups) {
|
|
110
|
+
builder = builder.addUniqueConstraint(`${table.name}_${group.join('_')}_unique`, group as any);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// What the shape says, told to the storage. Table-level rather than inline: a
|
|
114
|
+
// named constraint is what a later migration can find and replace, and the same
|
|
115
|
+
// form will hold a cross-field check when one is declared.
|
|
116
|
+
for (const column of table.columns) {
|
|
117
|
+
const check = checkFor(column);
|
|
118
|
+
if (check) builder = builder.addCheckConstraint(`${table.name}_${column.name}_check`, check);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return builder.compile().sql;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `CREATE INDEX` for every column that asked for one.
|
|
126
|
+
*
|
|
127
|
+
* Separate statements, never part of `CREATE TABLE`: an index is not a constraint, it
|
|
128
|
+
* changes no answer — only what a read costs. `IF NOT EXISTS` everywhere it exists, so
|
|
129
|
+
* replaying the batch is safe (SQL Server has no such clause, same rule as the tables).
|
|
130
|
+
*/
|
|
131
|
+
export function indexSQL(table: TableDef, column: ColumnDef, dialectName: DialectName): string {
|
|
132
|
+
let builder = compiler(dialectName)
|
|
133
|
+
.schema.createIndex(`${table.name}_${column.name}_idx`)
|
|
134
|
+
.on(table.name)
|
|
135
|
+
.column(column.name);
|
|
136
|
+
if (dialectName !== 'mssql') builder = builder.ifNotExists();
|
|
137
|
+
return builder.compile().sql;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Every index one table asks for — one statement each. */
|
|
141
|
+
export function createIndexSQL(table: TableDef, dialectName: DialectName): string[] {
|
|
142
|
+
return table.columns
|
|
143
|
+
.filter((column) => column.index)
|
|
144
|
+
.map((column) => indexSQL(table, column, dialectName));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `ALTER TABLE ADD CONSTRAINT` for one FK `orderTables` deferred — closes a
|
|
149
|
+
* relation cycle once every table in it exists. Not available on SQLite (its
|
|
150
|
+
* `ALTER TABLE` is limited to RENAME/ADD COLUMN/RENAME COLUMN/DROP COLUMN) — a
|
|
151
|
+
* caller on that dialect never produces a deferred edge to render here.
|
|
152
|
+
*/
|
|
153
|
+
export function addForeignKeyConstraintSQL(table: TableDef, column: ColumnDef, dialectName: DialectName): string {
|
|
154
|
+
const ref = column.references!;
|
|
155
|
+
const name = `${table.name}_${column.name}_fk`;
|
|
156
|
+
let builder = compiler(dialectName)
|
|
157
|
+
.schema.alterTable(table.name)
|
|
158
|
+
.addForeignKeyConstraint(name, [column.name], ref.table, [ref.column]);
|
|
159
|
+
if (ref.onDelete) builder = builder.onDelete(ref.onDelete);
|
|
160
|
+
return builder.compile().sql;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── App-wide generation ───────────────────────────
|
|
164
|
+
|
|
165
|
+
export interface GenerateOptions {
|
|
166
|
+
/** Override table name resolution. Default: camelCase → snake_case + 's'. */
|
|
167
|
+
tableName?: (entityName: string) => string;
|
|
168
|
+
/** Target engine. Default: sqlite. */
|
|
169
|
+
dialect?: DialectName;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* `CREATE TABLE` for every entity the app hosts — scanned frond entities plus
|
|
174
|
+
* auth runtime entities when present.
|
|
175
|
+
*
|
|
176
|
+
* SQLite resolves FK targets lazily and accepts any order, and it has no
|
|
177
|
+
* `ALTER TABLE ADD CONSTRAINT` to close a cycle with — every FK stays inline,
|
|
178
|
+
* unordered. Every other engine needs a referenced table to exist first:
|
|
179
|
+
* `orderTables` sorts the batch and reports the edges a cycle forces to defer,
|
|
180
|
+
* rendered as `ALTER TABLE ADD CONSTRAINT` after every `CREATE TABLE`.
|
|
181
|
+
*
|
|
182
|
+
* Caveat for a repeat call (`autoMigrate`): `CREATE TABLE IF NOT EXISTS` is
|
|
183
|
+
* idempotent, `ADD CONSTRAINT` is not — on pg/mysql/mssql, calling this twice
|
|
184
|
+
* for an app with a relation cycle re-issues the same constraint and errors.
|
|
185
|
+
* The introspection-based `migrate()` (`diff.ts`) doesn't have this problem: it
|
|
186
|
+
* only ever emits a table's constraints once, the run that creates it.
|
|
187
|
+
*/
|
|
188
|
+
export function generateSQL(app: AppLike, options?: GenerateOptions): string[] {
|
|
189
|
+
const resolve = options?.tableName ?? toTableName;
|
|
190
|
+
const dialect = options?.dialect ?? 'sqlite';
|
|
191
|
+
const tables = toTables(app, resolve);
|
|
192
|
+
|
|
193
|
+
if (dialect === 'sqlite') {
|
|
194
|
+
return [
|
|
195
|
+
...tables.map((table) => createTableSQL(table, dialect)),
|
|
196
|
+
...tables.flatMap((table) => createIndexSQL(table, dialect)),
|
|
197
|
+
];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const { ordered, deferred } = orderTables(tables);
|
|
201
|
+
const deferredColumnsOf = new Map<string, Set<string>>();
|
|
202
|
+
for (const { table, column } of deferred) {
|
|
203
|
+
const names = deferredColumnsOf.get(table.name) ?? new Set<string>();
|
|
204
|
+
names.add(column.name);
|
|
205
|
+
deferredColumnsOf.set(table.name, names);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const creates = ordered.map((table) =>
|
|
209
|
+
createTableSQL(table, dialect, { skipReferences: deferredColumnsOf.get(table.name) }),
|
|
210
|
+
);
|
|
211
|
+
const constraints = deferred.map(({ table, column }) => addForeignKeyConstraintSQL(table, column, dialect));
|
|
212
|
+
// Indexes last: every table exists by then, and an index on a table that does not is
|
|
213
|
+
// the one ordering mistake this pass can make.
|
|
214
|
+
const indexes = ordered.flatMap((table) => createIndexSQL(table, dialect));
|
|
215
|
+
return [...creates, ...constraints, ...indexes];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Anything that can run a statement. `exec` is accepted alongside `execute` so a
|
|
220
|
+
* raw better-sqlite3 handle drops in unchanged.
|
|
221
|
+
*/
|
|
222
|
+
export type SqlSink =
|
|
223
|
+
| { execute(sql: string): unknown }
|
|
224
|
+
| { exec(sql: string): unknown };
|
|
225
|
+
|
|
226
|
+
function runOn(sink: SqlSink, statement: string): unknown {
|
|
227
|
+
return 'execute' in sink ? sink.execute(statement) : sink.exec(statement);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Create every missing table. Additive only — an existing table is left alone.
|
|
232
|
+
*
|
|
233
|
+
* Stays SYNCHRONOUS when the sink is (a raw better-sqlite3 handle), so a caller
|
|
234
|
+
* that doesn't await still gets its tables before the next statement. Returns a
|
|
235
|
+
* promise only when the sink actually returns one.
|
|
236
|
+
*/
|
|
237
|
+
export function autoMigrate(app: AppLike, sink: SqlSink, options?: GenerateOptions): void | Promise<void> {
|
|
238
|
+
const pending = generateSQL(app, options)
|
|
239
|
+
.map((statement) => runOn(sink, statement))
|
|
240
|
+
.filter((result): result is Promise<unknown> => typeof (result as any)?.then === 'function');
|
|
241
|
+
return pending.length ? Promise.all(pending).then(() => undefined) : undefined;
|
|
242
|
+
}
|
package/src/dialect.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialect — the only place that speaks SQL.
|
|
3
|
+
*
|
|
4
|
+
* A dialect answers two questions and nothing else: which column type carries
|
|
5
|
+
* this shape, and does this engine support `RETURNING`. Everything structural
|
|
6
|
+
* (which columns exist, which are keys) is decided upstream in `TableDef`.
|
|
7
|
+
*/
|
|
8
|
+
import type { ColumnDef } from './table.js';
|
|
9
|
+
|
|
10
|
+
export type DialectName = 'sqlite' | 'pg' | 'mysql' | 'mssql';
|
|
11
|
+
|
|
12
|
+
export interface Dialect {
|
|
13
|
+
name: DialectName;
|
|
14
|
+
/**
|
|
15
|
+
* SQL type for a column. `keyed` is true when the column belongs to a primary
|
|
16
|
+
* key — MySQL and SQL Server cannot index an unbounded text column, so they
|
|
17
|
+
* narrow to a bounded varchar there.
|
|
18
|
+
*/
|
|
19
|
+
columnType(column: ColumnDef, keyed: boolean): string;
|
|
20
|
+
/**
|
|
21
|
+
* Does `INSERT … RETURNING` work? MySQL has no such clause and SQL Server
|
|
22
|
+
* spells it `OUTPUT`; both take the insert-then-select path instead.
|
|
23
|
+
*/
|
|
24
|
+
supportsReturning: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* How many values one statement may bind — what splits a batch read into several.
|
|
27
|
+
*
|
|
28
|
+
* A key set comes from a PAGE, and a page has no ceiling (`list()` with no limit
|
|
29
|
+
* reads the table), so `where id in (…)` eventually meets the engine's limit.
|
|
30
|
+
* Measured on SQLite: 32 766 binds, and 32 767 answers `too many SQL variables`.
|
|
31
|
+
* SQL Server is the low one at 2100, which is why this is per dialect and not one
|
|
32
|
+
* constant — a batch that works on SQLite and dies on SQL Server is the same value
|
|
33
|
+
* behaving differently per engine, the thing this file exists to absorb.
|
|
34
|
+
*
|
|
35
|
+
* The number below is the limit MINUS a margin for the other values a statement
|
|
36
|
+
* carries (a filter, a cursor): a batch read is never the only thing in the query.
|
|
37
|
+
*/
|
|
38
|
+
maxBindings: number;
|
|
39
|
+
/**
|
|
40
|
+
* How this engine spells "write it, or replace what is there".
|
|
41
|
+
*
|
|
42
|
+
* `'on conflict'` is the standard clause (SQLite, Postgres); MySQL spells the same
|
|
43
|
+
* thing `ON DUPLICATE KEY UPDATE`; SQL Server has only `MERGE`, a different statement
|
|
44
|
+
* with different semantics — so it answers `false` and the port refuses by name
|
|
45
|
+
* rather than emulating a write with a read in front of it, which would be a lie
|
|
46
|
+
* about atomicity in an engine that has no transaction here either.
|
|
47
|
+
*/
|
|
48
|
+
upsert: 'on conflict' | 'on duplicate key' | false;
|
|
49
|
+
/**
|
|
50
|
+
* Is this the engine refusing a duplicate, rather than failing?
|
|
51
|
+
*
|
|
52
|
+
* A driver reports it as a plain `Error` whose wording is the engine's own, so only a
|
|
53
|
+
* dialect can tell — the same reason `maxBindings` and `upsert` live here. Without it
|
|
54
|
+
* every engine's phrasing would be matched in one place, and this file exists so no
|
|
55
|
+
* other one learns a dialect.
|
|
56
|
+
*
|
|
57
|
+
* A false negative costs an INTERNAL_ERROR where a CONFLICT was due — which is what
|
|
58
|
+
* every engine answered before this existed, so nothing is made worse by a gap.
|
|
59
|
+
*/
|
|
60
|
+
isUniqueViolation(error: unknown): boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The message a driver hands up, however it wrapped it. */
|
|
64
|
+
function messageOf(error: unknown): string {
|
|
65
|
+
const e = error as { message?: unknown; code?: unknown; cause?: unknown } | null;
|
|
66
|
+
const own = `${typeof e?.message === 'string' ? e.message : ''} ${typeof e?.code === 'string' ? e.code : ''}`;
|
|
67
|
+
// Kysely wraps a driver error, and D1 wraps it again: the wording is often one level down.
|
|
68
|
+
return e?.cause ? `${own} ${messageOf(e.cause)}` : own;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Bounded length for a key column, when the shape doesn't state its own. */
|
|
72
|
+
const KEY_LENGTH = 255;
|
|
73
|
+
|
|
74
|
+
function keyLength(column: ColumnDef): number {
|
|
75
|
+
const declared = column.bounds?.maxLength;
|
|
76
|
+
return declared !== undefined && declared > 0 && declared <= KEY_LENGTH ? declared : KEY_LENGTH;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const sqliteDialect: Dialect = {
|
|
80
|
+
// SQLite: `UNIQUE constraint failed: products.sku` — and D1 relays it verbatim.
|
|
81
|
+
isUniqueViolation: (error) => /UNIQUE constraint failed/i.test(messageOf(error)),
|
|
82
|
+
upsert: 'on conflict',
|
|
83
|
+
// SQLITE_MAX_VARIABLE_NUMBER is 32766 on any build since 3.32 (measured on better-sqlite3).
|
|
84
|
+
maxBindings: 30000,
|
|
85
|
+
name: 'sqlite',
|
|
86
|
+
supportsReturning: true,
|
|
87
|
+
// SQLite has one integer, one float and one text type — a boolean is an int,
|
|
88
|
+
// JSON is text. Any column may be a key, so `keyed` changes nothing.
|
|
89
|
+
columnType(column) {
|
|
90
|
+
switch (column.shape?.type) {
|
|
91
|
+
case 'integer':
|
|
92
|
+
case 'boolean':
|
|
93
|
+
return 'integer';
|
|
94
|
+
case 'number':
|
|
95
|
+
return 'real';
|
|
96
|
+
default:
|
|
97
|
+
return 'text';
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const pgDialect: Dialect = {
|
|
103
|
+
// Postgres: SQLSTATE 23505, which `pg` puts on `code` — the one engine that names it
|
|
104
|
+
// in a way no translation can blur.
|
|
105
|
+
isUniqueViolation: (error) => /23505/.test(messageOf(error)),
|
|
106
|
+
upsert: 'on conflict',
|
|
107
|
+
// the wire protocol counts parameters in an int16 — 65535.
|
|
108
|
+
maxBindings: 60000,
|
|
109
|
+
name: 'pg',
|
|
110
|
+
supportsReturning: true,
|
|
111
|
+
// Postgres has real types for everything, and `text` is indexable — so a key
|
|
112
|
+
// needs no narrowing.
|
|
113
|
+
columnType(column) {
|
|
114
|
+
switch (column.shape?.type) {
|
|
115
|
+
case 'integer':
|
|
116
|
+
return 'integer';
|
|
117
|
+
case 'number':
|
|
118
|
+
return 'double precision';
|
|
119
|
+
case 'boolean':
|
|
120
|
+
return 'boolean';
|
|
121
|
+
case 'object':
|
|
122
|
+
case 'array':
|
|
123
|
+
return 'jsonb';
|
|
124
|
+
default:
|
|
125
|
+
return 'text';
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const mysqlDialect: Dialect = {
|
|
131
|
+
// MySQL: ER_DUP_ENTRY / 1062, `Duplicate entry '…' for key '…'`.
|
|
132
|
+
isUniqueViolation: (error) => /ER_DUP_ENTRY|Duplicate entry/i.test(messageOf(error)),
|
|
133
|
+
upsert: 'on duplicate key',
|
|
134
|
+
// no parameter ceiling of its own; max_allowed_packet is what gives way, and it grows with the VALUES not the count.
|
|
135
|
+
maxBindings: 60000,
|
|
136
|
+
name: 'mysql',
|
|
137
|
+
supportsReturning: false,
|
|
138
|
+
columnType(column, keyed) {
|
|
139
|
+
switch (column.shape?.type) {
|
|
140
|
+
case 'integer':
|
|
141
|
+
return 'int';
|
|
142
|
+
case 'number':
|
|
143
|
+
return 'double';
|
|
144
|
+
case 'boolean':
|
|
145
|
+
return 'boolean';
|
|
146
|
+
case 'object':
|
|
147
|
+
case 'array':
|
|
148
|
+
return 'json';
|
|
149
|
+
default:
|
|
150
|
+
// TEXT cannot take part in a key without a prefix length.
|
|
151
|
+
return keyed ? `varchar(${keyLength(column)})` : 'text';
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const mssqlDialect: Dialect = {
|
|
157
|
+
// SQL Server: 2627 for a constraint, 2601 for a unique index — two numbers, one fact.
|
|
158
|
+
isUniqueViolation: (error) => /\b(2627|2601)\b|Violation of UNIQUE KEY/i.test(messageOf(error)),
|
|
159
|
+
upsert: false,
|
|
160
|
+
// 2100 parameters per statement, the lowest of the four by a wide margin.
|
|
161
|
+
maxBindings: 2000,
|
|
162
|
+
name: 'mssql',
|
|
163
|
+
supportsReturning: false,
|
|
164
|
+
columnType(column, keyed) {
|
|
165
|
+
switch (column.shape?.type) {
|
|
166
|
+
case 'integer':
|
|
167
|
+
return 'int';
|
|
168
|
+
case 'number':
|
|
169
|
+
return 'float';
|
|
170
|
+
case 'boolean':
|
|
171
|
+
return 'bit';
|
|
172
|
+
case 'object':
|
|
173
|
+
case 'array':
|
|
174
|
+
return 'nvarchar(max)';
|
|
175
|
+
default:
|
|
176
|
+
// nvarchar(max) is not indexable — a key narrows to a bounded length.
|
|
177
|
+
return keyed ? `nvarchar(${keyLength(column)})` : 'nvarchar(max)';
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const dialects: Record<DialectName, Dialect> = {
|
|
183
|
+
sqlite: sqliteDialect,
|
|
184
|
+
pg: pgDialect,
|
|
185
|
+
mysql: mysqlDialect,
|
|
186
|
+
mssql: mssqlDialect,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export function resolveDialect(name: DialectName): Dialect {
|
|
190
|
+
const dialect = dialects[name];
|
|
191
|
+
if (!dialect) throw new Error(`Unknown SQL dialect '${name}'. Known: ${Object.keys(dialects).join(', ')}`);
|
|
192
|
+
return dialect;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The type this column is emitted with — what the entity stated when it named THIS
|
|
197
|
+
* engine, the shape's own answer otherwise.
|
|
198
|
+
*
|
|
199
|
+
* The fallback is what keeps the statement local: `columnType: { pg: 'tsvector' }` leaves
|
|
200
|
+
* SQLite exactly where it was, so the same entity still boots on every dialect.
|
|
201
|
+
*/
|
|
202
|
+
export function columnTypeFor(dialect: Dialect, column: ColumnDef, keyed: boolean): string {
|
|
203
|
+
return column.stated?.columnType?.[dialect.name] ?? dialect.columnType(column, keyed);
|
|
204
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff — what the database is missing, compared to what the entities describe.
|
|
3
|
+
*
|
|
4
|
+
* Two states, one comparison, one realisation. The desired state comes from the
|
|
5
|
+
* entities; the actual one from Kysely's introspection, which is already
|
|
6
|
+
* engine-agnostic. The comparison itself is pure — no IO, no SQL.
|
|
7
|
+
*
|
|
8
|
+
* ADDITIVE ONLY, and that incapacity is the guarantee: a missing table is
|
|
9
|
+
* created, a missing column is added, and **nothing else ever happens**. Drops,
|
|
10
|
+
* renames and type changes are human intentions — a rename is not even
|
|
11
|
+
* detectable from a diff (it reads as a drop plus an add). Those belong in a
|
|
12
|
+
* written migration, never in an automatic pass.
|
|
13
|
+
*/
|
|
14
|
+
import { sql, type Kysely } from 'kysely';
|
|
15
|
+
import { addForeignKeyConstraintSQL, compiler, createTableSQL, indexSQL, type GenerateOptions } from './ddl.js';
|
|
16
|
+
import { checkFor } from './check.js';
|
|
17
|
+
import { columnTypeFor, resolveDialect, type DialectName } from './dialect.js';
|
|
18
|
+
import {
|
|
19
|
+
isKeyed,
|
|
20
|
+
orderTables,
|
|
21
|
+
toTables,
|
|
22
|
+
toTableName,
|
|
23
|
+
type AppLike,
|
|
24
|
+
type ColumnDef,
|
|
25
|
+
type TableDef,
|
|
26
|
+
} from './table.js';
|
|
27
|
+
|
|
28
|
+
/** What the database actually holds: column names per table. */
|
|
29
|
+
export type SchemaState = Map<string, Set<string>>;
|
|
30
|
+
|
|
31
|
+
/** Read the live schema. Only names are needed — an additive pass never inspects types. */
|
|
32
|
+
export async function actualState(db: Kysely<any>): Promise<SchemaState> {
|
|
33
|
+
const state: SchemaState = new Map();
|
|
34
|
+
for (const table of await db.introspection.getTables()) {
|
|
35
|
+
if (table.isView) continue;
|
|
36
|
+
state.set(table.name, new Set(table.columns.map((column) => column.name)));
|
|
37
|
+
}
|
|
38
|
+
return state;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Project the app's entities into the tables they ask for. */
|
|
42
|
+
export function desiredTables(app: AppLike, options?: GenerateOptions): TableDef[] {
|
|
43
|
+
return toTables(app, options?.tableName ?? toTableName);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type Change =
|
|
47
|
+
| { kind: 'createTable'; table: TableDef; deferredColumns?: string[] }
|
|
48
|
+
| { kind: 'addColumn'; table: TableDef; column: ColumnDef }
|
|
49
|
+
| { kind: 'addConstraint'; table: TableDef; column: ColumnDef }
|
|
50
|
+
| { kind: 'createIndex'; table: TableDef; column: ColumnDef };
|
|
51
|
+
|
|
52
|
+
/** Compare the two states. Pure — the only place that decides what is missing. */
|
|
53
|
+
export function delta(desired: TableDef[], actual: SchemaState): Change[] {
|
|
54
|
+
const changes: Change[] = [];
|
|
55
|
+
for (const table of desired) {
|
|
56
|
+
const existing = actual.get(table.name);
|
|
57
|
+
if (!existing) {
|
|
58
|
+
changes.push({ kind: 'createTable', table });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
for (const column of table.columns) {
|
|
62
|
+
if (!existing.has(column.name)) changes.push({ kind: 'addColumn', table, column });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Indexes, unconditionally: this pass reads column NAMES from the live schema, so it
|
|
66
|
+
// cannot see whether an index exists. `CREATE INDEX IF NOT EXISTS` is idempotent, so
|
|
67
|
+
// proposing it every time is cheaper and more honest than introspecting to guess —
|
|
68
|
+
// the alternative would be an index that a `unique()` added later never gets.
|
|
69
|
+
for (const table of desired) {
|
|
70
|
+
for (const column of table.columns) {
|
|
71
|
+
if (column.index) changes.push({ kind: 'createIndex', table, column });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return changes;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Order the changes `delta` found, dialect-aware — `delta` itself stays pure and
|
|
79
|
+
* unordered, this is the one place that adds engine knowledge to the plan.
|
|
80
|
+
*
|
|
81
|
+
* SQLite resolves FK targets lazily and accepts any order, with no `ALTER TABLE
|
|
82
|
+
* ADD CONSTRAINT` to defer to — changes pass through unchanged. Every other
|
|
83
|
+
* engine needs a `createTable`'s FK targets to already exist: `orderTables`
|
|
84
|
+
* sorts the NEW tables among themselves and reports the edges a cycle forces to
|
|
85
|
+
* defer as `addConstraint` changes. An `addColumn` always lands last — its
|
|
86
|
+
* table already exists (that's why it's `addColumn` and not `createTable`), but
|
|
87
|
+
* its FK target might be one of THIS batch's new tables, so it waits until
|
|
88
|
+
* every `createTable`/`addConstraint` above it has run.
|
|
89
|
+
*/
|
|
90
|
+
export function orderChanges(changes: Change[], dialectName: DialectName): Change[] {
|
|
91
|
+
if (dialectName === 'sqlite') return changes;
|
|
92
|
+
|
|
93
|
+
const creates = changes.filter((c): c is Extract<Change, { kind: 'createTable' }> => c.kind === 'createTable');
|
|
94
|
+
const addColumns = changes.filter((c) => c.kind === 'addColumn');
|
|
95
|
+
const indexes = changes.filter((c) => c.kind === 'createIndex');
|
|
96
|
+
|
|
97
|
+
const { ordered, deferred } = orderTables(creates.map((c) => c.table));
|
|
98
|
+
const deferredColumnsOf = new Map<string, Set<string>>();
|
|
99
|
+
for (const { table, column } of deferred) {
|
|
100
|
+
const names = deferredColumnsOf.get(table.name) ?? new Set<string>();
|
|
101
|
+
names.add(column.name);
|
|
102
|
+
deferredColumnsOf.set(table.name, names);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const createChanges: Change[] = ordered.map((table) => {
|
|
106
|
+
const names = deferredColumnsOf.get(table.name);
|
|
107
|
+
return names ? { kind: 'createTable', table, deferredColumns: [...names] } : { kind: 'createTable', table };
|
|
108
|
+
});
|
|
109
|
+
const constraintChanges: Change[] = deferred.map(({ table, column }) => ({ kind: 'addConstraint', table, column }));
|
|
110
|
+
|
|
111
|
+
// Indexes last: the column they stand on may be one this very batch added.
|
|
112
|
+
return [...createChanges, ...constraintChanges, ...addColumns, ...indexes];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Render one change.
|
|
117
|
+
*
|
|
118
|
+
* A column added to a populated table cannot be `NOT NULL` without a default —
|
|
119
|
+
* every engine refuses it. So an added column keeps its `NOT NULL` only when a
|
|
120
|
+
* default answers the existing rows; otherwise it lands nullable, and tightening
|
|
121
|
+
* it is a written migration.
|
|
122
|
+
*/
|
|
123
|
+
export function changeSQL(change: Change, dialectName: DialectName): string {
|
|
124
|
+
const dialect = resolveDialect(dialectName);
|
|
125
|
+
if (change.kind === 'createTable') {
|
|
126
|
+
// Reuse the same renderer as a fresh install — one builder, no drift.
|
|
127
|
+
const skip = change.deferredColumns ? new Set(change.deferredColumns) : undefined;
|
|
128
|
+
return createTableSQL(change.table, dialectName, { skipReferences: skip });
|
|
129
|
+
}
|
|
130
|
+
if (change.kind === 'addConstraint') {
|
|
131
|
+
return addForeignKeyConstraintSQL(change.table, change.column, dialectName);
|
|
132
|
+
}
|
|
133
|
+
if (change.kind === 'createIndex') {
|
|
134
|
+
// One statement per change — `migrate` runs them one by one, and no driver here
|
|
135
|
+
// accepts a batch.
|
|
136
|
+
return indexSQL(change.table, change.column, dialectName);
|
|
137
|
+
}
|
|
138
|
+
const { table, column } = change;
|
|
139
|
+
const type = columnTypeFor(dialect, column, isKeyed(table, column));
|
|
140
|
+
return compiler(dialectName)
|
|
141
|
+
.schema.alterTable(table.name)
|
|
142
|
+
.addColumn(column.name, sql.raw(type) as any, (col) => {
|
|
143
|
+
let built = col;
|
|
144
|
+
if (column.default !== undefined) {
|
|
145
|
+
built = built.defaultTo(column.default);
|
|
146
|
+
if (!column.nullable) built = built.notNull();
|
|
147
|
+
}
|
|
148
|
+
if (column.references) {
|
|
149
|
+
built = built.references(`${column.references.table}.${column.references.column}`);
|
|
150
|
+
if (column.references.onDelete) built = built.onDelete(column.references.onDelete);
|
|
151
|
+
}
|
|
152
|
+
// Inline rather than a named table constraint: SQLite cannot ALTER one in, and
|
|
153
|
+
// the column is new, so no existing row can be caught out by it. A column that
|
|
154
|
+
// arrives later is bounded like a column that was there from the start.
|
|
155
|
+
const check = checkFor(column);
|
|
156
|
+
if (check) built = built.check(check);
|
|
157
|
+
return built;
|
|
158
|
+
})
|
|
159
|
+
.compile().sql;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Everything the database is missing, as statements ready to run. */
|
|
163
|
+
export async function planMigration(
|
|
164
|
+
app: AppLike,
|
|
165
|
+
db: Kysely<any>,
|
|
166
|
+
options?: GenerateOptions,
|
|
167
|
+
): Promise<{ changes: Change[]; statements: string[] }> {
|
|
168
|
+
const dialect = options?.dialect ?? 'sqlite';
|
|
169
|
+
const changes = orderChanges(delta(desiredTables(app, options), await actualState(db)), dialect);
|
|
170
|
+
return { changes, statements: changes.map((change) => changeSQL(change, dialect)) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Bring the database up to what the entities describe — additively.
|
|
175
|
+
*
|
|
176
|
+
* Returns what it did, so a caller can log or refuse. Replaces the old
|
|
177
|
+
* create-if-not-exists pass: it also catches a field added to an existing entity,
|
|
178
|
+
* which used to be silently ignored.
|
|
179
|
+
*/
|
|
180
|
+
/**
|
|
181
|
+
* Bring the schema up to date. Takes the setup itself — `migrate(app, setup)` — so the
|
|
182
|
+
* common case never has to reach into `setup.db`, the one handle that meets no judge.
|
|
183
|
+
* A bare Kysely instance is still accepted, for a caller who holds only that.
|
|
184
|
+
*/
|
|
185
|
+
export async function migrate(
|
|
186
|
+
app: AppLike,
|
|
187
|
+
target: Kysely<any> | { db: Kysely<any> },
|
|
188
|
+
options?: GenerateOptions,
|
|
189
|
+
): Promise<Change[]> {
|
|
190
|
+
const db = (target as { db?: Kysely<any> }).db ?? (target as Kysely<any>);
|
|
191
|
+
const { changes, statements } = await planMigration(app, db, options);
|
|
192
|
+
for (const statement of statements) {
|
|
193
|
+
await sql.raw(statement).execute(db);
|
|
194
|
+
}
|
|
195
|
+
return changes;
|
|
196
|
+
}
|
package/src/fields.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an entity may state for THIS adapter, declared from OUTSIDE `@fougere/schema` —
|
|
3
|
+
* which names no engine and no column type, and must not learn one to let this exist.
|
|
4
|
+
*
|
|
5
|
+
* It replaces what the shape would have given, and only for the engine it names: an
|
|
6
|
+
* engine absent here keeps the shape's answer, so the entity still boots on every
|
|
7
|
+
* dialect. What an engine must HONOR is not stated here — it is a decision, and it
|
|
8
|
+
* belongs in `fougere.config.ts` beside `remotes:`, `sources:` and `ports:`.
|
|
9
|
+
*/
|
|
10
|
+
import type { DialectName } from './dialect.js';
|
|
11
|
+
|
|
12
|
+
/** What sql holds, addressed by field — the shape every augmentation of the registry takes. */
|
|
13
|
+
export type SqlFields<K extends string> = Partial<Record<K, SqlField>>;
|
|
14
|
+
|
|
15
|
+
export interface SqlField {
|
|
16
|
+
/** The column type to emit, per engine. An engine absent here keeps the shape's own. */
|
|
17
|
+
columnType?: Partial<Record<DialectName, string>>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare module '@fougere/schema' {
|
|
21
|
+
interface FougereEntityAdapters<K extends string> {
|
|
22
|
+
sql?: SqlFields<K>;
|
|
23
|
+
}
|
|
24
|
+
}
|