@easy-cms/drizzle 0.5.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 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. */
@@ -86,6 +95,9 @@ interface ChildModel {
86
95
  readonly path: readonly string[];
87
96
  readonly field: Field;
88
97
  readonly table: TableModel;
98
+ /** Localized: rows carry `_locale`, and the data holds `{ [locale]: rows }`. */
99
+ readonly locales?: readonly string[];
100
+ readonly defaultLocale?: string;
89
101
  }
90
102
  interface TableModel {
91
103
  readonly name: string;
@@ -108,6 +120,12 @@ interface SchemaModel {
108
120
  readonly tables: Readonly<Record<string, AnyTable>>;
109
121
  /** Changes whenever the generated schema changes. Used to detect missing migrations. */
110
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[];
111
129
  }
112
130
  declare const snake: (name: string) => string;
113
131
  /** Builds Drizzle tables for every collection and global in the config. */
@@ -136,6 +154,12 @@ declare class Migrator {
136
154
  private snapshotOf;
137
155
  /** SQL statements that turn `prev` into `cur`. Renames become drop + add unless interactive. */
138
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;
139
163
  ensureTable(): Promise<void>;
140
164
  private record;
141
165
  private applied;
@@ -158,4 +182,4 @@ declare class Migrator {
158
182
  private files;
159
183
  }
160
184
 
161
- 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
@@ -26,7 +26,12 @@ var columnPath = (column) => column.locale === void 0 ? column.path : [...column
26
26
  function rowToData(model, row) {
27
27
  const data = {};
28
28
  for (const column of model.columns) setAt(data, columnPath(column), row[column.column] ?? null);
29
- for (const child of model.children) setAt(data, child.path, []);
29
+ for (const child of model.children)
30
+ setAt(
31
+ data,
32
+ child.path,
33
+ child.locales ? Object.fromEntries(child.locales.map((locale) => [locale, []])) : []
34
+ );
30
35
  return data;
31
36
  }
32
37
  function dataToRow(model, data) {
@@ -55,8 +60,12 @@ async function attachChildren(db, model, rows, docs) {
55
60
  childRows.forEach((childRow, i) => {
56
61
  const index = indexById.get(childRow._parent_id);
57
62
  if (index === void 0) return;
58
- const list = getAt(docs[index], child.path);
59
- list.push(child.kind === "array" ? { id: childRow.id, ...childDocs?.[i] } : childRow.value);
63
+ const locale = child.locales ? String(childRow._locale) : void 0;
64
+ const path = locale === void 0 ? child.path : [...child.path, locale];
65
+ const list = getAt(docs[index], path);
66
+ if (!Array.isArray(list)) return;
67
+ const id = locale === void 0 ? childRow.id : String(childRow.id).replace(new RegExp(`:${locale}$`), "");
68
+ list.push(child.kind === "array" ? { id, ...childDocs?.[i] } : childRow.value);
60
69
  });
61
70
  }
62
71
  }
@@ -77,20 +86,41 @@ function toDocument(row, data, drafts) {
77
86
  async function insertChildren(db, model, parentId, data) {
78
87
  for (const child of model.children) {
79
88
  const value = getAt(data, child.path);
89
+ if (child.locales) {
90
+ if (!value || typeof value !== "object") continue;
91
+ for (const locale of child.locales) {
92
+ const items = value[locale];
93
+ if (Array.isArray(items) && items.length > 0)
94
+ await insertChild(db, child, parentId, items, locale);
95
+ }
96
+ continue;
97
+ }
80
98
  if (!Array.isArray(value) || value.length === 0) continue;
81
99
  await insertChild(db, child, parentId, value);
82
100
  }
83
101
  }
84
- async function insertChild(db, child, parentId, items) {
102
+ async function insertChild(db, child, parentId, items, locale) {
103
+ const localeColumn = locale === void 0 ? {} : { _locale: locale };
85
104
  if (child.kind === "values") {
86
- const rows = items.map((value, i) => ({ _parent_id: parentId, _order: i, value }));
105
+ const rows = items.map((value, i) => ({
106
+ _parent_id: parentId,
107
+ _order: i,
108
+ value,
109
+ ...localeColumn
110
+ }));
87
111
  for (const part of chunks(rows)) await db.insert(child.table.table).values(part);
88
112
  return;
89
113
  }
90
114
  for (const [i, item] of items.entries()) {
91
115
  const rowData = item;
92
- const id = rowData.id;
93
- await db.insert(child.table.table).values({ ...dataToRow(child.table, rowData), id, _parent_id: parentId, _order: i });
116
+ const id = locale === void 0 ? rowData.id : `${rowData.id}:${locale}`;
117
+ await db.insert(child.table.table).values({
118
+ ...dataToRow(child.table, rowData),
119
+ id,
120
+ _parent_id: parentId,
121
+ _order: i,
122
+ ...localeColumn
123
+ });
94
124
  await insertChildren(db, child.table, id, rowData);
95
125
  }
96
126
  }
@@ -138,6 +168,248 @@ async function deleteDocument(db, model, id) {
138
168
  import { mkdir, readdir, readFile, writeFile } from "fs/promises";
139
169
  import { join } from "path";
140
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
141
413
  var BREAKPOINT = "--> statement-breakpoint";
142
414
  var TABLE_PARTS = [
143
415
  "columns",
@@ -166,6 +438,17 @@ function unionSnapshot(prev, cur) {
166
438
  }
167
439
  return merged;
168
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
+ }
169
452
  var DESTRUCTIVE = /\b(DROP TABLE|DROP COLUMN)\b|__new_/i;
170
453
  var DEV_PUSH = "dev";
171
454
  var Migrator = class {
@@ -179,15 +462,58 @@ var Migrator = class {
179
462
  q(name) {
180
463
  return this.options.dialect.name === "postgres" ? `"${name}"` : `\`${name}\``;
181
464
  }
182
- snapshotOf(schema) {
183
- return this.options.dialect.snapshot(schema ? schema.tables : {});
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 };
184
469
  }
185
470
  /** SQL statements that turn `prev` into `cur`. Renames become drop + add unless interactive. */
186
471
  async diff(prev, cur, interactive) {
187
472
  const { dialect } = this.options;
188
- if (interactive) return dialect.migration(prev, cur);
189
- const union = unionSnapshot(prev, cur);
190
- return [...await dialect.migration(prev, union), ...await dialect.migration(union, cur)];
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;
191
517
  }
192
518
  async ensureTable() {
193
519
  await this.runner.query(this.options.dialect.migrationsTableSQL(this.options.table));
@@ -387,7 +713,44 @@ var SYSTEM_COLUMNS = {
387
713
  var col2 = (model, name) => model.table[name];
388
714
  var ID_FIELD = { name: "id", type: "relationship", to: "" };
389
715
  var LIKE_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "email", "slug", "select"]);
390
- function resolvePath(model, segments, drafts) {
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) {
391
754
  const [first] = segments;
392
755
  if (first === void 0) throw new QueryError("empty field path");
393
756
  if (segments.length === 1) {
@@ -413,19 +776,39 @@ function resolvePath(model, segments, drafts) {
413
776
  continue;
414
777
  }
415
778
  const child = model.children.find((c) => c.path.join(".") === path.join("."));
779
+ if (child?.locales) {
780
+ const next = segments[i + 1];
781
+ const named = next !== void 0 && child.locales.includes(next);
782
+ return {
783
+ kind: "child",
784
+ child,
785
+ rest: segments.slice(i + (named ? 2 : 1)),
786
+ locale: named ? next : child.defaultLocale
787
+ };
788
+ }
416
789
  if (child) return { kind: "child", child, rest: segments.slice(i + 1) };
417
- const locale = field.localized ? segments[i + 1] : void 0;
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;
418
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
+ }
419
805
  if (last < segments.length - 1) {
420
806
  throw new QueryError(`Cannot query inside "${shown}" (${field.type} field)`);
421
807
  }
422
808
  if (field.type === "json" || field.type === "richText") {
423
809
  throw new QueryError(`Cannot query "${shown}": ${field.type} fields are not queryable`);
424
810
  }
425
- const same = (c) => c.path.join(".") === path.join(".");
426
- const column = field.localized ? model.columns.find(
427
- (c) => same(c) && (locale === void 0 ? c.defaultLocale : c.locale === locale)
428
- ) : model.columns.find(same);
811
+ const column = columnOf();
429
812
  if (field.localized && !column)
430
813
  throw new QueryError(`Unknown locale "${locale}" for "${shown}"`);
431
814
  if (!column) throw new QueryError(`Unknown field "${shown}"`);
@@ -481,14 +864,16 @@ function operatorSQL(dialect, column, field, op, raw, path) {
481
864
  }
482
865
  }
483
866
  var WhereBuilder = class {
484
- constructor(db, dialect, drafts) {
867
+ constructor(db, dialect, drafts, localization = null) {
485
868
  this.db = db;
486
869
  this.dialect = dialect;
487
870
  this.drafts = drafts;
871
+ this.localization = localization;
488
872
  }
489
873
  db;
490
874
  dialect;
491
875
  drafts;
876
+ localization;
492
877
  build(model, where) {
493
878
  if (where === void 0) return void 0;
494
879
  if (typeof where !== "object" || where === null || Array.isArray(where)) {
@@ -513,19 +898,40 @@ var WhereBuilder = class {
513
898
  }
514
899
  const entries = Object.entries(operators).filter(([, v]) => v !== void 0);
515
900
  if (entries.length === 0) throw new QueryError(`"${path}" has no operator`);
516
- const target = resolvePath(model, path.split("."), this.drafts && model.kind === "root");
901
+ const target = resolvePath(
902
+ model,
903
+ path.split("."),
904
+ this.drafts && model.kind === "root",
905
+ this.localization
906
+ );
517
907
  const parts = entries.map(([op, value]) => {
518
908
  if (!OPERATORS.has(op)) throw new QueryError(`Unknown operator "${op}" on "${path}"`);
519
909
  if (target.kind === "column")
520
910
  return operatorSQL(this.dialect, target.column, target.field, op, value, path);
521
- return this.child(model, target.child, target.rest, op, value, path);
911
+ if (target.kind === "blocks") return this.blocks(target, op, value, path);
912
+ return this.child(model, target.child, target.rest, op, value, path, target.locale);
522
913
  });
523
914
  return parts.length === 1 ? parts[0] : and(...parts);
524
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
+ }
525
930
  /** Conditions on array rows or hasMany values become (NOT) EXISTS subqueries. */
526
- child(parent, child, rest, op, value, path) {
931
+ child(parent, child, rest, op, value, path, locale) {
527
932
  const table = child.table;
528
- const link = eq2(col2(table, "_parent_id"), col2(parent, "id"));
933
+ const parentLink = eq2(col2(table, "_parent_id"), col2(parent, "id"));
934
+ const link = locale === void 0 ? parentLink : and(parentLink, eq2(col2(table, "_locale"), locale));
529
935
  const subquery = (condition) => exists(
530
936
  this.db.select({ one: sql`1` }).from(table.table).where(condition ? and(link, condition) : link)
531
937
  );
@@ -553,7 +959,7 @@ var WhereBuilder = class {
553
959
  const order = sort.map((entry) => {
554
960
  const descending = entry.startsWith("-");
555
961
  const path = descending ? entry.slice(1) : entry;
556
- const target = resolvePath(model, path.split("."), this.drafts);
962
+ const target = resolvePath(model, path.split("."), this.drafts, this.localization);
557
963
  if (target.kind !== "column") throw new QueryError(`Cannot sort by "${path}"`);
558
964
  return descending ? desc(target.column) : asc2(target.column);
559
965
  });
@@ -562,207 +968,6 @@ var WhereBuilder = class {
562
968
  }
563
969
  };
564
970
 
565
- // src/schema.ts
566
- import { createHash } from "crypto";
567
- import { ConfigError } from "@easy-cms/core";
568
- function migrationsTableName(prefix) {
569
- return `${prefix}migrations`;
570
- }
571
- var snake = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^A-Za-z0-9]+/g, "_").toLowerCase();
572
- function columnKind(field) {
573
- switch (field.type) {
574
- case "number":
575
- return "number";
576
- case "boolean":
577
- return "boolean";
578
- case "json":
579
- case "richText":
580
- return "json";
581
- case "upload":
582
- case "relationship":
583
- return "integer";
584
- default:
585
- return "text";
586
- }
587
- }
588
- function buildSchema(config, prefix, dialect) {
589
- const builder = {
590
- dialect,
591
- prefix,
592
- tableNames: /* @__PURE__ */ new Set([migrationsTableName(prefix)]),
593
- // The hash covers the logical schema only, so it stays stable across releases for the same config.
594
- description: [],
595
- localization: config.localization
596
- };
597
- const collections = /* @__PURE__ */ new Map();
598
- const tables = {};
599
- for (const collection of config.collections) {
600
- const root = buildTable(
601
- builder,
602
- `${prefix}${snake(collection.slug)}`,
603
- "root",
604
- collection.fields,
605
- {
606
- drafts: collection.drafts === true
607
- }
608
- );
609
- collections.set(collection.slug, { config: collection, root });
610
- collectTables(root, tables);
611
- }
612
- const globalsName = claimName(builder, `${prefix}globals`);
613
- const globals = dialect.table(
614
- globalsName,
615
- {
616
- slug: dialect.text("slug").primaryKey(),
617
- data: dialect.json("data").notNull(),
618
- status: dialect.text("status"),
619
- updated_at: dialect.text("updated_at").notNull()
620
- },
621
- () => []
622
- );
623
- tables[globalsName] = globals;
624
- builder.description.push(["globals", globalsName]);
625
- const hash = createHash("sha256").update(JSON.stringify(builder.description)).digest("hex").slice(0, 16);
626
- return { collections, globals, tables, hash };
627
- }
628
- function collectTables(model, into) {
629
- into[model.name] = model.table;
630
- for (const child of model.children) collectTables(child.table, into);
631
- }
632
- function claimName(builder, name) {
633
- if (builder.tableNames.has(name)) {
634
- throw new ConfigError([
635
- {
636
- path: name,
637
- message: `two tables would be named "${name}"`,
638
- hint: "rename one of the collections or fields"
639
- }
640
- ]);
641
- }
642
- builder.tableNames.add(name);
643
- return name;
644
- }
645
- function makeColumn(dialect, name, kind) {
646
- switch (kind) {
647
- case "number":
648
- return dialect.number(name);
649
- case "integer":
650
- return dialect.integer(name);
651
- case "boolean":
652
- return dialect.boolean(name);
653
- case "json":
654
- return dialect.json(name);
655
- case "text":
656
- return dialect.text(name);
657
- }
658
- }
659
- function buildTable(builder, name, kind, fields, options) {
660
- const { dialect } = builder;
661
- claimName(builder, name);
662
- const columns = {};
663
- const described = [];
664
- const columnModels = [];
665
- const children = [];
666
- const indexes = [];
667
- const add = (column, builderColumn, desc2) => {
668
- if (Object.hasOwn(columns, column)) {
669
- throw new ConfigError([
670
- {
671
- path: `${name}.${column}`,
672
- message: `two fields would use the column "${column}"`,
673
- hint: "rename one of them"
674
- }
675
- ]);
676
- }
677
- columns[column] = builderColumn;
678
- described.push([column, desc2]);
679
- };
680
- if (kind === "root") {
681
- add("id", dialect.serial("id"), "id:int");
682
- add("created_at", dialect.text("created_at").notNull(), "text!");
683
- add("updated_at", dialect.text("updated_at").notNull(), "text!");
684
- if (options.drafts) add("status", dialect.text("status").notNull().default("draft"), "status");
685
- indexes.push({ column: "created_at", unique: false });
686
- } else {
687
- const parentKind = options.parentIdKind ?? "integer";
688
- add(
689
- "id",
690
- kind === "array" ? dialect.text("id").primaryKey() : dialect.serial("id"),
691
- `id:${kind}`
692
- );
693
- add(
694
- "_parent_id",
695
- parentKind === "text" ? dialect.text("_parent_id").notNull() : dialect.integer("_parent_id").notNull(),
696
- parentKind
697
- );
698
- add("_order", dialect.integer("_order").notNull(), "int!");
699
- indexes.push({ column: "_parent_id", unique: false });
700
- if (kind === "values" && options.valueField) {
701
- const valueKind = columnKind(options.valueField);
702
- add("value", makeColumn(dialect, "value", valueKind), valueKind);
703
- indexes.push({ column: "value", unique: false });
704
- }
705
- }
706
- const walk = (list, path, topLevel) => {
707
- for (const field of list) {
708
- const fieldPath = [...path, field.name];
709
- const base = fieldPath.map(snake).join("_");
710
- if (field.type === "group") {
711
- walk(field.fields, fieldPath, false);
712
- } else if (field.type === "array") {
713
- const child = buildTable(builder, `${name}__${base}`, "array", field.fields, {
714
- parentIdKind: kind === "array" ? "text" : "integer"
715
- });
716
- children.push({ kind: "array", path: fieldPath, field, table: child });
717
- described.push([base, "array", child.name]);
718
- } else if ((field.type === "select" || field.type === "relationship") && field.hasMany) {
719
- const child = buildTable(builder, `${name}__${base}`, "values", [], {
720
- parentIdKind: kind === "array" ? "text" : "integer",
721
- valueField: field
722
- });
723
- children.push({ kind: "values", path: fieldPath, field, table: child });
724
- described.push([base, "values", child.name]);
725
- } else {
726
- const kindOfColumn = columnKind(field);
727
- const localization = field.localized ? builder.localization : null;
728
- const targets = localization ? localization.locales.map((locale) => ({
729
- column: locale === localization.defaultLocale ? base : `${base}__${snake(locale)}`,
730
- locale
731
- })) : [{ column: base, locale: void 0 }];
732
- for (const { column, locale } of targets) {
733
- add(column, makeColumn(dialect, column, kindOfColumn), kindOfColumn);
734
- columnModels.push(
735
- locale === void 0 ? { path: fieldPath, field, column } : {
736
- path: fieldPath,
737
- field,
738
- column,
739
- locale,
740
- defaultLocale: locale === localization?.defaultLocale
741
- }
742
- );
743
- if (topLevel && kind === "root" && (field.unique || field.type === "slug")) {
744
- indexes.push({ column, unique: true });
745
- } else if (field.index || field.unique || field.type === "relationship" || field.type === "upload") {
746
- indexes.push({ column, unique: false });
747
- }
748
- }
749
- }
750
- }
751
- };
752
- walk(fields, [], true);
753
- const table = dialect.table(
754
- name,
755
- columns,
756
- (t) => indexes.map(({ column, unique }) => {
757
- const col3 = t[column];
758
- return unique ? dialect.uniqueIndex(`${name}_${column}_unique`).on(col3) : dialect.index(`${name}_${column}_idx`).on(col3);
759
- })
760
- );
761
- builder.description.push([name, kind, described, indexes]);
762
- const model = { name, kind, table, fields, columns: columnModels, children };
763
- return options.valueField ? { ...model, valueField: options.valueField } : model;
764
- }
765
-
766
971
  // src/database.ts
767
972
  var DEFAULT_TABLE_PREFIX = "ecms_";
768
973
  var DEFAULT_MIGRATION_DIR = "easy-cms/migrations";
@@ -795,7 +1000,10 @@ var DrizzleDatabase = class {
795
1000
  this.migrator = migrator;
796
1001
  this.db = connection.db;
797
1002
  for (const [slug, model] of schema.collections) {
798
- this.builders.set(slug, new WhereBuilder(this.db, dialect, model.config.drafts === true));
1003
+ this.builders.set(
1004
+ slug,
1005
+ new WhereBuilder(this.db, dialect, model.config.drafts === true, schema.localization)
1006
+ );
799
1007
  }
800
1008
  }
801
1009
  connection;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/drizzle",
3
- "version": "0.5.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.5.0"
26
+ "@easy-cms/core": "^0.7.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@easy-cms/core": "^0.5.0"
29
+ "@easy-cms/core": "^0.7.0"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"