@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 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
- 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 };
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
- if (interactive) return dialect.migration(prev, cur);
219
- const union = unionSnapshot(prev, cur);
220
- 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;
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
- 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) {
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
- 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;
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" || field.type === "blocks") {
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 same = (c) => c.path.join(".") === path.join(".");
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(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
+ );
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(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
+ );
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.6.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.6.0"
26
+ "@easy-cms/core": "^0.7.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@easy-cms/core": "^0.6.0"
29
+ "@easy-cms/core": "^0.7.0"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"