@easy-cms/drizzle 0.6.0 → 0.7.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/dist/index.d.ts +22 -1
- package/dist/index.js +384 -246
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,10 @@ interface SqlRunner {
|
|
|
19
19
|
/** Runs the statements in one transaction: all or nothing. */
|
|
20
20
|
transaction(statements: readonly Statement[]): Promise<void>;
|
|
21
21
|
}
|
|
22
|
+
/** How to read a value out of a JSON element for comparing. */
|
|
23
|
+
type JsonValueType = 'text' | 'number' | 'integer' | 'boolean';
|
|
24
|
+
/** A value inside the JSON element being tested, e.g. `['heading', 'en']`. */
|
|
25
|
+
type JsonGetter = (path: readonly string[], type: JsonValueType) => SQL;
|
|
22
26
|
/** What differs between SQL dialects. */
|
|
23
27
|
interface Dialect {
|
|
24
28
|
readonly name: 'sqlite' | 'postgres';
|
|
@@ -39,6 +43,11 @@ interface Dialect {
|
|
|
39
43
|
json(name: string): ColumnBuilder;
|
|
40
44
|
/** Case-insensitive substring match; `pattern` is already escaped and wrapped in `%`. */
|
|
41
45
|
like(column: AnyColumn, pattern: string): SQL;
|
|
46
|
+
/**
|
|
47
|
+
* True when some element of a JSON array column matches `condition` (any element when it
|
|
48
|
+
* returns undefined). Path segments are field names, already checked against the config.
|
|
49
|
+
*/
|
|
50
|
+
someElement(column: AnyColumn, condition: (get: JsonGetter) => SQL | undefined): SQL;
|
|
42
51
|
/** Placeholder for the n-th parameter (1-based) in raw SQL. */
|
|
43
52
|
param(n: number): string;
|
|
44
53
|
/** CREATE TABLE IF NOT EXISTS for the migrations table. */
|
|
@@ -111,6 +120,12 @@ interface SchemaModel {
|
|
|
111
120
|
readonly tables: Readonly<Record<string, AnyTable>>;
|
|
112
121
|
/** Changes whenever the generated schema changes. Used to detect missing migrations. */
|
|
113
122
|
readonly hash: string;
|
|
123
|
+
/** Locales of localized fields, recorded with snapshots so a new default locale keeps data. */
|
|
124
|
+
readonly localization: SchemaLocalization | null;
|
|
125
|
+
}
|
|
126
|
+
interface SchemaLocalization {
|
|
127
|
+
readonly defaultLocale: string;
|
|
128
|
+
readonly locales: readonly string[];
|
|
114
129
|
}
|
|
115
130
|
declare const snake: (name: string) => string;
|
|
116
131
|
/** Builds Drizzle tables for every collection and global in the config. */
|
|
@@ -139,6 +154,12 @@ declare class Migrator {
|
|
|
139
154
|
private snapshotOf;
|
|
140
155
|
/** SQL statements that turn `prev` into `cur`. Renames become drop + add unless interactive. */
|
|
141
156
|
private diff;
|
|
157
|
+
/**
|
|
158
|
+
* A new default locale changes which locale the plain column holds (`title` is the default
|
|
159
|
+
* locale's, `title__en` another's). Moves the values so every locale keeps its own.
|
|
160
|
+
* Snapshots from before 0.7 do not record the locales, so nothing can be moved for them.
|
|
161
|
+
*/
|
|
162
|
+
private localeMoves;
|
|
142
163
|
ensureTable(): Promise<void>;
|
|
143
164
|
private record;
|
|
144
165
|
private applied;
|
|
@@ -161,4 +182,4 @@ declare class Migrator {
|
|
|
161
182
|
private files;
|
|
162
183
|
}
|
|
163
184
|
|
|
164
|
-
export { type AnyColumn, type AnyTable, type ColumnBuilder, type Connection, DEFAULT_MIGRATION_DIR, DEFAULT_TABLE_PREFIX, type Dialect, type DrizzleAdapterOptions, type DrizzleDb, type IndexBuilder, Migrator, type SchemaModel, type Snapshot, type SqlRunner, type Statement, buildSchema, connectDatabase, snake, unionSnapshot };
|
|
185
|
+
export { type AnyColumn, type AnyTable, type ColumnBuilder, type Connection, DEFAULT_MIGRATION_DIR, DEFAULT_TABLE_PREFIX, type Dialect, type DrizzleAdapterOptions, type DrizzleDb, type IndexBuilder, type JsonGetter, type JsonValueType, Migrator, type SchemaModel, type Snapshot, type SqlRunner, type Statement, buildSchema, connectDatabase, snake, unionSnapshot };
|
package/dist/index.js
CHANGED
|
@@ -168,6 +168,248 @@ async function deleteDocument(db, model, id) {
|
|
|
168
168
|
import { mkdir, readdir, readFile, writeFile } from "fs/promises";
|
|
169
169
|
import { join } from "path";
|
|
170
170
|
import { SchemaError } from "@easy-cms/core";
|
|
171
|
+
|
|
172
|
+
// src/schema.ts
|
|
173
|
+
import { createHash } from "crypto";
|
|
174
|
+
import { ConfigError } from "@easy-cms/core";
|
|
175
|
+
function tableModels(schema) {
|
|
176
|
+
const all = [];
|
|
177
|
+
const walk = (model) => {
|
|
178
|
+
all.push(model);
|
|
179
|
+
for (const child of model.children) walk(child.table);
|
|
180
|
+
};
|
|
181
|
+
for (const collection of schema.collections.values()) walk(collection.root);
|
|
182
|
+
return all;
|
|
183
|
+
}
|
|
184
|
+
function migrationsTableName(prefix) {
|
|
185
|
+
return `${prefix}migrations`;
|
|
186
|
+
}
|
|
187
|
+
var snake = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toLowerCase();
|
|
188
|
+
function columnKind(field) {
|
|
189
|
+
switch (field.type) {
|
|
190
|
+
case "number":
|
|
191
|
+
return "number";
|
|
192
|
+
case "boolean":
|
|
193
|
+
return "boolean";
|
|
194
|
+
case "json":
|
|
195
|
+
case "richText":
|
|
196
|
+
// Blocks are stored whole as JSON: rows of different kinds don't fit one child table.
|
|
197
|
+
case "blocks":
|
|
198
|
+
return "json";
|
|
199
|
+
case "upload":
|
|
200
|
+
case "relationship":
|
|
201
|
+
return "integer";
|
|
202
|
+
default:
|
|
203
|
+
return "text";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function buildSchema(config, prefix, dialect) {
|
|
207
|
+
const builder = {
|
|
208
|
+
dialect,
|
|
209
|
+
prefix,
|
|
210
|
+
tableNames: /* @__PURE__ */ new Set([migrationsTableName(prefix)]),
|
|
211
|
+
// The hash covers the logical schema only, so it stays stable across releases for the same config.
|
|
212
|
+
description: [],
|
|
213
|
+
localization: config.localization
|
|
214
|
+
};
|
|
215
|
+
const collections = /* @__PURE__ */ new Map();
|
|
216
|
+
const tables = {};
|
|
217
|
+
for (const collection of config.collections) {
|
|
218
|
+
const root = buildTable(
|
|
219
|
+
builder,
|
|
220
|
+
`${prefix}${snake(collection.slug)}`,
|
|
221
|
+
"root",
|
|
222
|
+
collection.fields,
|
|
223
|
+
{
|
|
224
|
+
drafts: collection.drafts === true
|
|
225
|
+
}
|
|
226
|
+
);
|
|
227
|
+
collections.set(collection.slug, { config: collection, root });
|
|
228
|
+
collectTables(root, tables);
|
|
229
|
+
}
|
|
230
|
+
const globalsName = claimName(builder, `${prefix}globals`);
|
|
231
|
+
const globals = dialect.table(
|
|
232
|
+
globalsName,
|
|
233
|
+
{
|
|
234
|
+
slug: dialect.text("slug").primaryKey(),
|
|
235
|
+
data: dialect.json("data").notNull(),
|
|
236
|
+
status: dialect.text("status"),
|
|
237
|
+
updated_at: dialect.text("updated_at").notNull()
|
|
238
|
+
},
|
|
239
|
+
() => []
|
|
240
|
+
);
|
|
241
|
+
tables[globalsName] = globals;
|
|
242
|
+
builder.description.push(["globals", globalsName]);
|
|
243
|
+
const hash = createHash("sha256").update(JSON.stringify(builder.description)).digest("hex").slice(0, 16);
|
|
244
|
+
const localization = config.localization ? { defaultLocale: config.localization.defaultLocale, locales: config.localization.locales } : null;
|
|
245
|
+
return { collections, globals, tables, hash, localization };
|
|
246
|
+
}
|
|
247
|
+
function collectTables(model, into) {
|
|
248
|
+
into[model.name] = model.table;
|
|
249
|
+
for (const child of model.children) collectTables(child.table, into);
|
|
250
|
+
}
|
|
251
|
+
function claimName(builder, name) {
|
|
252
|
+
if (builder.tableNames.has(name)) {
|
|
253
|
+
throw new ConfigError([
|
|
254
|
+
{
|
|
255
|
+
path: name,
|
|
256
|
+
message: `two tables would be named "${name}"`,
|
|
257
|
+
hint: "rename one of the collections or fields"
|
|
258
|
+
}
|
|
259
|
+
]);
|
|
260
|
+
}
|
|
261
|
+
builder.tableNames.add(name);
|
|
262
|
+
return name;
|
|
263
|
+
}
|
|
264
|
+
function makeColumn(dialect, name, kind) {
|
|
265
|
+
switch (kind) {
|
|
266
|
+
case "number":
|
|
267
|
+
return dialect.number(name);
|
|
268
|
+
case "integer":
|
|
269
|
+
return dialect.integer(name);
|
|
270
|
+
case "boolean":
|
|
271
|
+
return dialect.boolean(name);
|
|
272
|
+
case "json":
|
|
273
|
+
return dialect.json(name);
|
|
274
|
+
case "text":
|
|
275
|
+
return dialect.text(name);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function buildTable(builder, name, kind, fields, options) {
|
|
279
|
+
const { dialect } = builder;
|
|
280
|
+
claimName(builder, name);
|
|
281
|
+
const columns = {};
|
|
282
|
+
const described = [];
|
|
283
|
+
const columnModels = [];
|
|
284
|
+
const children = [];
|
|
285
|
+
const indexes = [];
|
|
286
|
+
const add = (column, builderColumn, desc2) => {
|
|
287
|
+
if (Object.hasOwn(columns, column)) {
|
|
288
|
+
throw new ConfigError([
|
|
289
|
+
{
|
|
290
|
+
path: `${name}.${column}`,
|
|
291
|
+
message: `two fields would use the column "${column}"`,
|
|
292
|
+
hint: "rename one of them"
|
|
293
|
+
}
|
|
294
|
+
]);
|
|
295
|
+
}
|
|
296
|
+
columns[column] = builderColumn;
|
|
297
|
+
described.push([column, desc2]);
|
|
298
|
+
};
|
|
299
|
+
if (kind === "root") {
|
|
300
|
+
add("id", dialect.serial("id"), "id:int");
|
|
301
|
+
add("created_at", dialect.text("created_at").notNull(), "text!");
|
|
302
|
+
add("updated_at", dialect.text("updated_at").notNull(), "text!");
|
|
303
|
+
if (options.drafts) add("status", dialect.text("status").notNull().default("draft"), "status");
|
|
304
|
+
indexes.push({ column: "created_at", unique: false });
|
|
305
|
+
} else {
|
|
306
|
+
const parentKind = options.parentIdKind ?? "integer";
|
|
307
|
+
add(
|
|
308
|
+
"id",
|
|
309
|
+
kind === "array" ? dialect.text("id").primaryKey() : dialect.serial("id"),
|
|
310
|
+
`id:${kind}`
|
|
311
|
+
);
|
|
312
|
+
add(
|
|
313
|
+
"_parent_id",
|
|
314
|
+
parentKind === "text" ? dialect.text("_parent_id").notNull() : dialect.integer("_parent_id").notNull(),
|
|
315
|
+
parentKind
|
|
316
|
+
);
|
|
317
|
+
add("_order", dialect.integer("_order").notNull(), "int!");
|
|
318
|
+
indexes.push({ column: "_parent_id", unique: false });
|
|
319
|
+
if (options.localized) {
|
|
320
|
+
add(
|
|
321
|
+
"_locale",
|
|
322
|
+
dialect.text("_locale").notNull().default(options.localized),
|
|
323
|
+
`locale:${options.localized}`
|
|
324
|
+
);
|
|
325
|
+
indexes.push({ column: "_locale", unique: false });
|
|
326
|
+
}
|
|
327
|
+
if (kind === "values" && options.valueField) {
|
|
328
|
+
const valueKind = columnKind(options.valueField);
|
|
329
|
+
add("value", makeColumn(dialect, "value", valueKind), valueKind);
|
|
330
|
+
indexes.push({ column: "value", unique: false });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const walk = (list, path, topLevel) => {
|
|
334
|
+
for (const field of list) {
|
|
335
|
+
const fieldPath = [...path, field.name];
|
|
336
|
+
const base = fieldPath.map(snake).join("_");
|
|
337
|
+
if (field.type === "group") {
|
|
338
|
+
walk(field.fields, fieldPath, false);
|
|
339
|
+
} else if (field.type === "array") {
|
|
340
|
+
const localization = field.localized ? builder.localization : null;
|
|
341
|
+
const child = buildTable(builder, `${name}__${base}`, "array", field.fields, {
|
|
342
|
+
parentIdKind: kind === "array" ? "text" : "integer",
|
|
343
|
+
...localization ? { localized: localization.defaultLocale } : {}
|
|
344
|
+
});
|
|
345
|
+
children.push({
|
|
346
|
+
kind: "array",
|
|
347
|
+
path: fieldPath,
|
|
348
|
+
field,
|
|
349
|
+
table: child,
|
|
350
|
+
...localeInfo(localization)
|
|
351
|
+
});
|
|
352
|
+
described.push([base, "array", child.name]);
|
|
353
|
+
} else if ((field.type === "select" || field.type === "relationship") && field.hasMany) {
|
|
354
|
+
const localization = field.localized ? builder.localization : null;
|
|
355
|
+
const child = buildTable(builder, `${name}__${base}`, "values", [], {
|
|
356
|
+
parentIdKind: kind === "array" ? "text" : "integer",
|
|
357
|
+
valueField: field,
|
|
358
|
+
...localization ? { localized: localization.defaultLocale } : {}
|
|
359
|
+
});
|
|
360
|
+
children.push({
|
|
361
|
+
kind: "values",
|
|
362
|
+
path: fieldPath,
|
|
363
|
+
field,
|
|
364
|
+
table: child,
|
|
365
|
+
...localeInfo(localization)
|
|
366
|
+
});
|
|
367
|
+
described.push([base, "values", child.name]);
|
|
368
|
+
} else {
|
|
369
|
+
const kindOfColumn = columnKind(field);
|
|
370
|
+
const localization = field.localized ? builder.localization : null;
|
|
371
|
+
const targets = localization ? localization.locales.map((locale) => ({
|
|
372
|
+
column: locale === localization.defaultLocale ? base : `${base}__${snake(locale)}`,
|
|
373
|
+
locale
|
|
374
|
+
})) : [{ column: base, locale: void 0 }];
|
|
375
|
+
for (const { column, locale } of targets) {
|
|
376
|
+
add(column, makeColumn(dialect, column, kindOfColumn), kindOfColumn);
|
|
377
|
+
columnModels.push(
|
|
378
|
+
locale === void 0 ? { path: fieldPath, field, column } : {
|
|
379
|
+
path: fieldPath,
|
|
380
|
+
field,
|
|
381
|
+
column,
|
|
382
|
+
locale,
|
|
383
|
+
defaultLocale: locale === localization?.defaultLocale
|
|
384
|
+
}
|
|
385
|
+
);
|
|
386
|
+
if (topLevel && kind === "root" && (field.unique || field.type === "slug")) {
|
|
387
|
+
indexes.push({ column, unique: true });
|
|
388
|
+
} else if (field.index || field.unique || field.type === "relationship" || field.type === "upload") {
|
|
389
|
+
indexes.push({ column, unique: false });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
walk(fields, [], true);
|
|
396
|
+
const table = dialect.table(
|
|
397
|
+
name,
|
|
398
|
+
columns,
|
|
399
|
+
(t) => indexes.map(({ column, unique }) => {
|
|
400
|
+
const col3 = t[column];
|
|
401
|
+
return unique ? dialect.uniqueIndex(`${name}_${column}_unique`).on(col3) : dialect.index(`${name}_${column}_idx`).on(col3);
|
|
402
|
+
})
|
|
403
|
+
);
|
|
404
|
+
builder.description.push([name, kind, described, indexes]);
|
|
405
|
+
const model = { name, kind, table, fields, columns: columnModels, children };
|
|
406
|
+
return options.valueField ? { ...model, valueField: options.valueField } : model;
|
|
407
|
+
}
|
|
408
|
+
function localeInfo(localization) {
|
|
409
|
+
return localization ? { locales: localization.locales, defaultLocale: localization.defaultLocale } : {};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// src/migrations.ts
|
|
171
413
|
var BREAKPOINT = "--> statement-breakpoint";
|
|
172
414
|
var TABLE_PARTS = [
|
|
173
415
|
"columns",
|
|
@@ -196,6 +438,17 @@ function unionSnapshot(prev, cur) {
|
|
|
196
438
|
}
|
|
197
439
|
return merged;
|
|
198
440
|
}
|
|
441
|
+
var META = "easyCms";
|
|
442
|
+
var metaOf = (snapshot) => snapshot[META];
|
|
443
|
+
function withoutMeta(snapshot) {
|
|
444
|
+
const { [META]: _, ...rest } = snapshot;
|
|
445
|
+
return rest;
|
|
446
|
+
}
|
|
447
|
+
function snapshotTable(snapshot, name) {
|
|
448
|
+
return Object.values(snapshot.tables ?? {}).find(
|
|
449
|
+
(t) => t.name === name
|
|
450
|
+
);
|
|
451
|
+
}
|
|
199
452
|
var DESTRUCTIVE = /\b(DROP TABLE|DROP COLUMN)\b|__new_/i;
|
|
200
453
|
var DEV_PUSH = "dev";
|
|
201
454
|
var Migrator = class {
|
|
@@ -209,15 +462,58 @@ var Migrator = class {
|
|
|
209
462
|
q(name) {
|
|
210
463
|
return this.options.dialect.name === "postgres" ? `"${name}"` : `\`${name}\``;
|
|
211
464
|
}
|
|
212
|
-
snapshotOf(schema) {
|
|
213
|
-
|
|
465
|
+
async snapshotOf(schema) {
|
|
466
|
+
const snapshot = await this.options.dialect.snapshot(schema ? schema.tables : {});
|
|
467
|
+
const meta = { localization: schema?.localization ?? null };
|
|
468
|
+
return { ...snapshot, [META]: meta };
|
|
214
469
|
}
|
|
215
470
|
/** SQL statements that turn `prev` into `cur`. Renames become drop + add unless interactive. */
|
|
216
471
|
async diff(prev, cur, interactive) {
|
|
217
472
|
const { dialect } = this.options;
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
473
|
+
const moves = this.localeMoves(prev, metaOf(prev), metaOf(cur));
|
|
474
|
+
const [from, to] = [withoutMeta(prev), withoutMeta(cur)];
|
|
475
|
+
if (interactive && moves.length === 0) return dialect.migration(from, to);
|
|
476
|
+
const union = unionSnapshot(from, to);
|
|
477
|
+
return [
|
|
478
|
+
...await dialect.migration(from, union),
|
|
479
|
+
...moves,
|
|
480
|
+
...await dialect.migration(union, to)
|
|
481
|
+
];
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* A new default locale changes which locale the plain column holds (`title` is the default
|
|
485
|
+
* locale's, `title__en` another's). Moves the values so every locale keeps its own.
|
|
486
|
+
* Snapshots from before 0.7 do not record the locales, so nothing can be moved for them.
|
|
487
|
+
*/
|
|
488
|
+
localeMoves(prev, before, after) {
|
|
489
|
+
const was = before?.localization;
|
|
490
|
+
const now = after?.localization;
|
|
491
|
+
if (!was || !now || was.defaultLocale === now.defaultLocale) return [];
|
|
492
|
+
const q = (name) => this.q(name);
|
|
493
|
+
const statements = [];
|
|
494
|
+
for (const model of tableModels(this.options.schema)) {
|
|
495
|
+
const old = snapshotTable(prev, model.name);
|
|
496
|
+
if (!old) continue;
|
|
497
|
+
const has = (column) => Object.hasOwn(old.columns ?? {}, column);
|
|
498
|
+
const keep = [];
|
|
499
|
+
const clear = [];
|
|
500
|
+
const fill = [];
|
|
501
|
+
for (const column of model.columns) {
|
|
502
|
+
if (!column.defaultLocale || !has(column.column)) continue;
|
|
503
|
+
const base = column.column;
|
|
504
|
+
const moved = model.columns.find(
|
|
505
|
+
(c) => c.path.join(".") === column.path.join(".") && c.locale === was.defaultLocale
|
|
506
|
+
);
|
|
507
|
+
if (moved && !has(moved.column)) keep.push(`${q(moved.column)} = ${q(base)}`);
|
|
508
|
+
clear.push(`${q(base)} = NULL`);
|
|
509
|
+
const incoming = `${base}__${snake(now.defaultLocale)}`;
|
|
510
|
+
if (has(incoming)) fill.push(`${q(base)} = ${q(incoming)}`);
|
|
511
|
+
}
|
|
512
|
+
for (const set of [keep, clear, fill]) {
|
|
513
|
+
if (set.length > 0) statements.push(`UPDATE ${q(model.name)} SET ${set.join(", ")}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return statements;
|
|
221
517
|
}
|
|
222
518
|
async ensureTable() {
|
|
223
519
|
await this.runner.query(this.options.dialect.migrationsTableSQL(this.options.table));
|
|
@@ -417,7 +713,44 @@ var SYSTEM_COLUMNS = {
|
|
|
417
713
|
var col2 = (model, name) => model.table[name];
|
|
418
714
|
var ID_FIELD = { name: "id", type: "relationship", to: "" };
|
|
419
715
|
var LIKE_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "email", "slug", "select"]);
|
|
420
|
-
|
|
716
|
+
var JSON_TYPES = {
|
|
717
|
+
number: "number",
|
|
718
|
+
boolean: "boolean",
|
|
719
|
+
relationship: "integer",
|
|
720
|
+
upload: "integer"
|
|
721
|
+
};
|
|
722
|
+
function resolveBlockValue(blocks, rest, shown, localization) {
|
|
723
|
+
if (rest.length === 1 && rest[0] === "blockType")
|
|
724
|
+
return { path: ["blockType"], type: "text", field: void 0 };
|
|
725
|
+
let fields = blocks.blocks.flatMap((block) => block.fields);
|
|
726
|
+
const path = [];
|
|
727
|
+
for (let i = 0; i < rest.length; i++) {
|
|
728
|
+
const segment = rest[i];
|
|
729
|
+
const field = fields.find((f) => f.name === segment);
|
|
730
|
+
const at = [shown, ...rest.slice(0, i + 1)].join(".");
|
|
731
|
+
if (!field) throw new QueryError(`Unknown field "${at}"`);
|
|
732
|
+
path.push(segment);
|
|
733
|
+
if (field.type === "group") {
|
|
734
|
+
fields = field.fields;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const hasMany = "hasMany" in field && field.hasMany === true;
|
|
738
|
+
if (hasMany || field.type === "array" || field.type === "blocks" || field.type === "json" || field.type === "richText") {
|
|
739
|
+
throw new QueryError(`Cannot query "${at}" inside blocks`);
|
|
740
|
+
}
|
|
741
|
+
let last = i;
|
|
742
|
+
if (field.localized && localization) {
|
|
743
|
+
const next = rest[i + 1];
|
|
744
|
+
const named = next !== void 0 && localization.locales.includes(next);
|
|
745
|
+
path.push(named ? next : localization.defaultLocale);
|
|
746
|
+
if (named) last = i + 1;
|
|
747
|
+
}
|
|
748
|
+
if (last < rest.length - 1) throw new QueryError(`Cannot query inside "${at}"`);
|
|
749
|
+
return { path, type: JSON_TYPES[field.type] ?? "text", field };
|
|
750
|
+
}
|
|
751
|
+
throw new QueryError(`"${[shown, ...rest].join(".")}" is a group; query one of its fields`);
|
|
752
|
+
}
|
|
753
|
+
function resolvePath(model, segments, drafts, localization = null) {
|
|
421
754
|
const [first] = segments;
|
|
422
755
|
if (first === void 0) throw new QueryError("empty field path");
|
|
423
756
|
if (segments.length === 1) {
|
|
@@ -454,18 +787,28 @@ function resolvePath(model, segments, drafts) {
|
|
|
454
787
|
};
|
|
455
788
|
}
|
|
456
789
|
if (child) return { kind: "child", child, rest: segments.slice(i + 1) };
|
|
457
|
-
|
|
790
|
+
let locale = field.localized ? segments[i + 1] : void 0;
|
|
791
|
+
if (field.type === "blocks" && locale !== void 0 && !localization?.locales.includes(locale))
|
|
792
|
+
locale = void 0;
|
|
458
793
|
const last = field.localized && locale !== void 0 ? i + 1 : i;
|
|
794
|
+
const same = (c) => c.path.join(".") === path.join(".");
|
|
795
|
+
const columnOf = () => field.localized ? model.columns.find(
|
|
796
|
+
(c) => same(c) && (locale === void 0 ? c.defaultLocale : c.locale === locale)
|
|
797
|
+
) : model.columns.find(same);
|
|
798
|
+
if (field.type === "blocks") {
|
|
799
|
+
const column2 = columnOf();
|
|
800
|
+
if (!column2) throw new QueryError(`Unknown locale "${locale}" for "${shown}"`);
|
|
801
|
+
const rest = segments.slice(last + 1);
|
|
802
|
+
const value = rest.length === 0 ? null : resolveBlockValue(field, rest, shown, localization);
|
|
803
|
+
return { kind: "blocks", column: col2(model, column2.column), value };
|
|
804
|
+
}
|
|
459
805
|
if (last < segments.length - 1) {
|
|
460
806
|
throw new QueryError(`Cannot query inside "${shown}" (${field.type} field)`);
|
|
461
807
|
}
|
|
462
|
-
if (field.type === "json" || field.type === "richText"
|
|
808
|
+
if (field.type === "json" || field.type === "richText") {
|
|
463
809
|
throw new QueryError(`Cannot query "${shown}": ${field.type} fields are not queryable`);
|
|
464
810
|
}
|
|
465
|
-
const
|
|
466
|
-
const column = field.localized ? model.columns.find(
|
|
467
|
-
(c) => same(c) && (locale === void 0 ? c.defaultLocale : c.locale === locale)
|
|
468
|
-
) : model.columns.find(same);
|
|
811
|
+
const column = columnOf();
|
|
469
812
|
if (field.localized && !column)
|
|
470
813
|
throw new QueryError(`Unknown locale "${locale}" for "${shown}"`);
|
|
471
814
|
if (!column) throw new QueryError(`Unknown field "${shown}"`);
|
|
@@ -521,14 +864,16 @@ function operatorSQL(dialect, column, field, op, raw, path) {
|
|
|
521
864
|
}
|
|
522
865
|
}
|
|
523
866
|
var WhereBuilder = class {
|
|
524
|
-
constructor(db, dialect, drafts) {
|
|
867
|
+
constructor(db, dialect, drafts, localization = null) {
|
|
525
868
|
this.db = db;
|
|
526
869
|
this.dialect = dialect;
|
|
527
870
|
this.drafts = drafts;
|
|
871
|
+
this.localization = localization;
|
|
528
872
|
}
|
|
529
873
|
db;
|
|
530
874
|
dialect;
|
|
531
875
|
drafts;
|
|
876
|
+
localization;
|
|
532
877
|
build(model, where) {
|
|
533
878
|
if (where === void 0) return void 0;
|
|
534
879
|
if (typeof where !== "object" || where === null || Array.isArray(where)) {
|
|
@@ -553,15 +898,35 @@ var WhereBuilder = class {
|
|
|
553
898
|
}
|
|
554
899
|
const entries = Object.entries(operators).filter(([, v]) => v !== void 0);
|
|
555
900
|
if (entries.length === 0) throw new QueryError(`"${path}" has no operator`);
|
|
556
|
-
const target = resolvePath(
|
|
901
|
+
const target = resolvePath(
|
|
902
|
+
model,
|
|
903
|
+
path.split("."),
|
|
904
|
+
this.drafts && model.kind === "root",
|
|
905
|
+
this.localization
|
|
906
|
+
);
|
|
557
907
|
const parts = entries.map(([op, value]) => {
|
|
558
908
|
if (!OPERATORS.has(op)) throw new QueryError(`Unknown operator "${op}" on "${path}"`);
|
|
559
909
|
if (target.kind === "column")
|
|
560
910
|
return operatorSQL(this.dialect, target.column, target.field, op, value, path);
|
|
911
|
+
if (target.kind === "blocks") return this.blocks(target, op, value, path);
|
|
561
912
|
return this.child(model, target.child, target.rest, op, value, path, target.locale);
|
|
562
913
|
});
|
|
563
914
|
return parts.length === 1 ? parts[0] : and(...parts);
|
|
564
915
|
}
|
|
916
|
+
/** Conditions inside blocks test each block of the JSON column: some block must match. */
|
|
917
|
+
blocks(target, op, value, path) {
|
|
918
|
+
const { value: inner } = target;
|
|
919
|
+
if (!inner) {
|
|
920
|
+
if (op !== "exists")
|
|
921
|
+
throw new QueryError(`"${path}" is a blocks field; query one of its fields`);
|
|
922
|
+
const any = this.dialect.someElement(target.column, () => void 0);
|
|
923
|
+
return value === false ? not(any) : any;
|
|
924
|
+
}
|
|
925
|
+
return this.dialect.someElement(
|
|
926
|
+
target.column,
|
|
927
|
+
(get) => operatorSQL(this.dialect, get(inner.path, inner.type), inner.field, op, value, path)
|
|
928
|
+
);
|
|
929
|
+
}
|
|
565
930
|
/** Conditions on array rows or hasMany values become (NOT) EXISTS subqueries. */
|
|
566
931
|
child(parent, child, rest, op, value, path, locale) {
|
|
567
932
|
const table = child.table;
|
|
@@ -594,7 +959,7 @@ var WhereBuilder = class {
|
|
|
594
959
|
const order = sort.map((entry) => {
|
|
595
960
|
const descending = entry.startsWith("-");
|
|
596
961
|
const path = descending ? entry.slice(1) : entry;
|
|
597
|
-
const target = resolvePath(model, path.split("."), this.drafts);
|
|
962
|
+
const target = resolvePath(model, path.split("."), this.drafts, this.localization);
|
|
598
963
|
if (target.kind !== "column") throw new QueryError(`Cannot sort by "${path}"`);
|
|
599
964
|
return descending ? desc(target.column) : asc2(target.column);
|
|
600
965
|
});
|
|
@@ -603,236 +968,6 @@ var WhereBuilder = class {
|
|
|
603
968
|
}
|
|
604
969
|
};
|
|
605
970
|
|
|
606
|
-
// src/schema.ts
|
|
607
|
-
import { createHash } from "crypto";
|
|
608
|
-
import { ConfigError } from "@easy-cms/core";
|
|
609
|
-
function migrationsTableName(prefix) {
|
|
610
|
-
return `${prefix}migrations`;
|
|
611
|
-
}
|
|
612
|
-
var snake = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toLowerCase();
|
|
613
|
-
function columnKind(field) {
|
|
614
|
-
switch (field.type) {
|
|
615
|
-
case "number":
|
|
616
|
-
return "number";
|
|
617
|
-
case "boolean":
|
|
618
|
-
return "boolean";
|
|
619
|
-
case "json":
|
|
620
|
-
case "richText":
|
|
621
|
-
// Blocks are stored whole as JSON: rows of different kinds don't fit one child table.
|
|
622
|
-
case "blocks":
|
|
623
|
-
return "json";
|
|
624
|
-
case "upload":
|
|
625
|
-
case "relationship":
|
|
626
|
-
return "integer";
|
|
627
|
-
default:
|
|
628
|
-
return "text";
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
function buildSchema(config, prefix, dialect) {
|
|
632
|
-
const builder = {
|
|
633
|
-
dialect,
|
|
634
|
-
prefix,
|
|
635
|
-
tableNames: /* @__PURE__ */ new Set([migrationsTableName(prefix)]),
|
|
636
|
-
// The hash covers the logical schema only, so it stays stable across releases for the same config.
|
|
637
|
-
description: [],
|
|
638
|
-
localization: config.localization
|
|
639
|
-
};
|
|
640
|
-
const collections = /* @__PURE__ */ new Map();
|
|
641
|
-
const tables = {};
|
|
642
|
-
for (const collection of config.collections) {
|
|
643
|
-
const root = buildTable(
|
|
644
|
-
builder,
|
|
645
|
-
`${prefix}${snake(collection.slug)}`,
|
|
646
|
-
"root",
|
|
647
|
-
collection.fields,
|
|
648
|
-
{
|
|
649
|
-
drafts: collection.drafts === true
|
|
650
|
-
}
|
|
651
|
-
);
|
|
652
|
-
collections.set(collection.slug, { config: collection, root });
|
|
653
|
-
collectTables(root, tables);
|
|
654
|
-
}
|
|
655
|
-
const globalsName = claimName(builder, `${prefix}globals`);
|
|
656
|
-
const globals = dialect.table(
|
|
657
|
-
globalsName,
|
|
658
|
-
{
|
|
659
|
-
slug: dialect.text("slug").primaryKey(),
|
|
660
|
-
data: dialect.json("data").notNull(),
|
|
661
|
-
status: dialect.text("status"),
|
|
662
|
-
updated_at: dialect.text("updated_at").notNull()
|
|
663
|
-
},
|
|
664
|
-
() => []
|
|
665
|
-
);
|
|
666
|
-
tables[globalsName] = globals;
|
|
667
|
-
builder.description.push(["globals", globalsName]);
|
|
668
|
-
const hash = createHash("sha256").update(JSON.stringify(builder.description)).digest("hex").slice(0, 16);
|
|
669
|
-
return { collections, globals, tables, hash };
|
|
670
|
-
}
|
|
671
|
-
function collectTables(model, into) {
|
|
672
|
-
into[model.name] = model.table;
|
|
673
|
-
for (const child of model.children) collectTables(child.table, into);
|
|
674
|
-
}
|
|
675
|
-
function claimName(builder, name) {
|
|
676
|
-
if (builder.tableNames.has(name)) {
|
|
677
|
-
throw new ConfigError([
|
|
678
|
-
{
|
|
679
|
-
path: name,
|
|
680
|
-
message: `two tables would be named "${name}"`,
|
|
681
|
-
hint: "rename one of the collections or fields"
|
|
682
|
-
}
|
|
683
|
-
]);
|
|
684
|
-
}
|
|
685
|
-
builder.tableNames.add(name);
|
|
686
|
-
return name;
|
|
687
|
-
}
|
|
688
|
-
function makeColumn(dialect, name, kind) {
|
|
689
|
-
switch (kind) {
|
|
690
|
-
case "number":
|
|
691
|
-
return dialect.number(name);
|
|
692
|
-
case "integer":
|
|
693
|
-
return dialect.integer(name);
|
|
694
|
-
case "boolean":
|
|
695
|
-
return dialect.boolean(name);
|
|
696
|
-
case "json":
|
|
697
|
-
return dialect.json(name);
|
|
698
|
-
case "text":
|
|
699
|
-
return dialect.text(name);
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
function buildTable(builder, name, kind, fields, options) {
|
|
703
|
-
const { dialect } = builder;
|
|
704
|
-
claimName(builder, name);
|
|
705
|
-
const columns = {};
|
|
706
|
-
const described = [];
|
|
707
|
-
const columnModels = [];
|
|
708
|
-
const children = [];
|
|
709
|
-
const indexes = [];
|
|
710
|
-
const add = (column, builderColumn, desc2) => {
|
|
711
|
-
if (Object.hasOwn(columns, column)) {
|
|
712
|
-
throw new ConfigError([
|
|
713
|
-
{
|
|
714
|
-
path: `${name}.${column}`,
|
|
715
|
-
message: `two fields would use the column "${column}"`,
|
|
716
|
-
hint: "rename one of them"
|
|
717
|
-
}
|
|
718
|
-
]);
|
|
719
|
-
}
|
|
720
|
-
columns[column] = builderColumn;
|
|
721
|
-
described.push([column, desc2]);
|
|
722
|
-
};
|
|
723
|
-
if (kind === "root") {
|
|
724
|
-
add("id", dialect.serial("id"), "id:int");
|
|
725
|
-
add("created_at", dialect.text("created_at").notNull(), "text!");
|
|
726
|
-
add("updated_at", dialect.text("updated_at").notNull(), "text!");
|
|
727
|
-
if (options.drafts) add("status", dialect.text("status").notNull().default("draft"), "status");
|
|
728
|
-
indexes.push({ column: "created_at", unique: false });
|
|
729
|
-
} else {
|
|
730
|
-
const parentKind = options.parentIdKind ?? "integer";
|
|
731
|
-
add(
|
|
732
|
-
"id",
|
|
733
|
-
kind === "array" ? dialect.text("id").primaryKey() : dialect.serial("id"),
|
|
734
|
-
`id:${kind}`
|
|
735
|
-
);
|
|
736
|
-
add(
|
|
737
|
-
"_parent_id",
|
|
738
|
-
parentKind === "text" ? dialect.text("_parent_id").notNull() : dialect.integer("_parent_id").notNull(),
|
|
739
|
-
parentKind
|
|
740
|
-
);
|
|
741
|
-
add("_order", dialect.integer("_order").notNull(), "int!");
|
|
742
|
-
indexes.push({ column: "_parent_id", unique: false });
|
|
743
|
-
if (options.localized) {
|
|
744
|
-
add(
|
|
745
|
-
"_locale",
|
|
746
|
-
dialect.text("_locale").notNull().default(options.localized),
|
|
747
|
-
`locale:${options.localized}`
|
|
748
|
-
);
|
|
749
|
-
indexes.push({ column: "_locale", unique: false });
|
|
750
|
-
}
|
|
751
|
-
if (kind === "values" && options.valueField) {
|
|
752
|
-
const valueKind = columnKind(options.valueField);
|
|
753
|
-
add("value", makeColumn(dialect, "value", valueKind), valueKind);
|
|
754
|
-
indexes.push({ column: "value", unique: false });
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
const walk = (list, path, topLevel) => {
|
|
758
|
-
for (const field of list) {
|
|
759
|
-
const fieldPath = [...path, field.name];
|
|
760
|
-
const base = fieldPath.map(snake).join("_");
|
|
761
|
-
if (field.type === "group") {
|
|
762
|
-
walk(field.fields, fieldPath, false);
|
|
763
|
-
} else if (field.type === "array") {
|
|
764
|
-
const localization = field.localized ? builder.localization : null;
|
|
765
|
-
const child = buildTable(builder, `${name}__${base}`, "array", field.fields, {
|
|
766
|
-
parentIdKind: kind === "array" ? "text" : "integer",
|
|
767
|
-
...localization ? { localized: localization.defaultLocale } : {}
|
|
768
|
-
});
|
|
769
|
-
children.push({
|
|
770
|
-
kind: "array",
|
|
771
|
-
path: fieldPath,
|
|
772
|
-
field,
|
|
773
|
-
table: child,
|
|
774
|
-
...localeInfo(localization)
|
|
775
|
-
});
|
|
776
|
-
described.push([base, "array", child.name]);
|
|
777
|
-
} else if ((field.type === "select" || field.type === "relationship") && field.hasMany) {
|
|
778
|
-
const localization = field.localized ? builder.localization : null;
|
|
779
|
-
const child = buildTable(builder, `${name}__${base}`, "values", [], {
|
|
780
|
-
parentIdKind: kind === "array" ? "text" : "integer",
|
|
781
|
-
valueField: field,
|
|
782
|
-
...localization ? { localized: localization.defaultLocale } : {}
|
|
783
|
-
});
|
|
784
|
-
children.push({
|
|
785
|
-
kind: "values",
|
|
786
|
-
path: fieldPath,
|
|
787
|
-
field,
|
|
788
|
-
table: child,
|
|
789
|
-
...localeInfo(localization)
|
|
790
|
-
});
|
|
791
|
-
described.push([base, "values", child.name]);
|
|
792
|
-
} else {
|
|
793
|
-
const kindOfColumn = columnKind(field);
|
|
794
|
-
const localization = field.localized ? builder.localization : null;
|
|
795
|
-
const targets = localization ? localization.locales.map((locale) => ({
|
|
796
|
-
column: locale === localization.defaultLocale ? base : `${base}__${snake(locale)}`,
|
|
797
|
-
locale
|
|
798
|
-
})) : [{ column: base, locale: void 0 }];
|
|
799
|
-
for (const { column, locale } of targets) {
|
|
800
|
-
add(column, makeColumn(dialect, column, kindOfColumn), kindOfColumn);
|
|
801
|
-
columnModels.push(
|
|
802
|
-
locale === void 0 ? { path: fieldPath, field, column } : {
|
|
803
|
-
path: fieldPath,
|
|
804
|
-
field,
|
|
805
|
-
column,
|
|
806
|
-
locale,
|
|
807
|
-
defaultLocale: locale === localization?.defaultLocale
|
|
808
|
-
}
|
|
809
|
-
);
|
|
810
|
-
if (topLevel && kind === "root" && (field.unique || field.type === "slug")) {
|
|
811
|
-
indexes.push({ column, unique: true });
|
|
812
|
-
} else if (field.index || field.unique || field.type === "relationship" || field.type === "upload") {
|
|
813
|
-
indexes.push({ column, unique: false });
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
};
|
|
819
|
-
walk(fields, [], true);
|
|
820
|
-
const table = dialect.table(
|
|
821
|
-
name,
|
|
822
|
-
columns,
|
|
823
|
-
(t) => indexes.map(({ column, unique }) => {
|
|
824
|
-
const col3 = t[column];
|
|
825
|
-
return unique ? dialect.uniqueIndex(`${name}_${column}_unique`).on(col3) : dialect.index(`${name}_${column}_idx`).on(col3);
|
|
826
|
-
})
|
|
827
|
-
);
|
|
828
|
-
builder.description.push([name, kind, described, indexes]);
|
|
829
|
-
const model = { name, kind, table, fields, columns: columnModels, children };
|
|
830
|
-
return options.valueField ? { ...model, valueField: options.valueField } : model;
|
|
831
|
-
}
|
|
832
|
-
function localeInfo(localization) {
|
|
833
|
-
return localization ? { locales: localization.locales, defaultLocale: localization.defaultLocale } : {};
|
|
834
|
-
}
|
|
835
|
-
|
|
836
971
|
// src/database.ts
|
|
837
972
|
var DEFAULT_TABLE_PREFIX = "ecms_";
|
|
838
973
|
var DEFAULT_MIGRATION_DIR = "easy-cms/migrations";
|
|
@@ -865,7 +1000,10 @@ var DrizzleDatabase = class {
|
|
|
865
1000
|
this.migrator = migrator;
|
|
866
1001
|
this.db = connection.db;
|
|
867
1002
|
for (const [slug, model] of schema.collections) {
|
|
868
|
-
this.builders.set(
|
|
1003
|
+
this.builders.set(
|
|
1004
|
+
slug,
|
|
1005
|
+
new WhereBuilder(this.db, dialect, model.config.drafts === true, schema.localization)
|
|
1006
|
+
);
|
|
869
1007
|
}
|
|
870
1008
|
}
|
|
871
1009
|
connection;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@easy-cms/drizzle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Shared Drizzle ORM layer for Easy CMS database adapters",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,10 +23,10 @@
|
|
|
23
23
|
"drizzle-orm": "0.45.3"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
|
-
"@easy-cms/core": "^0.
|
|
26
|
+
"@easy-cms/core": "^0.7.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@easy-cms/core": "^0.
|
|
29
|
+
"@easy-cms/core": "^0.7.0"
|
|
30
30
|
},
|
|
31
31
|
"publishConfig": {
|
|
32
32
|
"access": "public"
|