@dyrected/db-mysql 2.17.0 → 2.18.1

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.cjs CHANGED
@@ -50,6 +50,24 @@ function parseMysqlDuplicateKey(message) {
50
50
  }
51
51
  return { field, value };
52
52
  }
53
+ function jsonWhereExpr(field) {
54
+ const path = `JSON_EXTRACT(data, '$.${field}')`;
55
+ return `IF(JSON_TYPE(${path}) = 'NULL', NULL, IF(JSON_TYPE(${path}) = 'BOOLEAN', IF(${path} = TRUE, 1, 0), JSON_UNQUOTE(${path})))`;
56
+ }
57
+ var DATE_TYPES = /* @__PURE__ */ new Set(["date", "datetime"]);
58
+ var ISO_WITH_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/;
59
+ var MYSQL_DATETIME = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)?$/;
60
+ function toDatetimeColumn(value) {
61
+ if (typeof value !== "string" || !ISO_WITH_TIME.test(value)) return value;
62
+ const date = new Date(value);
63
+ if (Number.isNaN(date.getTime())) return value;
64
+ return date.toISOString().replace("T", " ").replace("Z", "");
65
+ }
66
+ function fromDatetimeColumn(value) {
67
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? value : value.toISOString();
68
+ if (typeof value === "string" && MYSQL_DATETIME.test(value)) return `${value.replace(" ", "T")}Z`;
69
+ return value;
70
+ }
53
71
  function isNumericOp(val) {
54
72
  return val !== null && typeof val === "object" && !Array.isArray(val) && ("increment" in val || "decrement" in val);
55
73
  }
@@ -352,7 +370,15 @@ FIX INSTRUCTIONS:
352
370
  console.log(`[dyrected/mysql] Promoting field "${field.name}" to column in ${tableName}`);
353
371
  const isUniqueOrIndexed = field.unique || indexedFields.has(field.name);
354
372
  let sqlType = isUniqueOrIndexed ? "VARCHAR(191)" : "TEXT";
373
+ if (!isUniqueOrIndexed) {
374
+ if ((field.type === "select" || field.type === "radio") && !field.hasMany) sqlType = "VARCHAR(100)";
375
+ else if (field.type === "relationship" && !field.hasMany) sqlType = "VARCHAR(191)";
376
+ else if (field.type === "email") sqlType = "VARCHAR(254)";
377
+ } else if (field.type === "email") {
378
+ sqlType = "VARCHAR(191)";
379
+ }
355
380
  if (field.type === "number") sqlType = "DECIMAL(19,4)";
381
+ if (field.type === "money") sqlType = "BIGINT";
356
382
  if (field.type === "boolean") sqlType = "TINYINT(1)";
357
383
  if (field.type === "date" || field.type === "datetime") sqlType = "DATETIME(3)";
358
384
  if (field.type === "textarea" || field.type === "richText" || field.type === "json") sqlType = "LONGTEXT";
@@ -370,6 +396,8 @@ FIX INSTRUCTIONS:
370
396
  let castExpr = `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}'))`;
371
397
  if (field.type === "number") {
372
398
  castExpr = `IF(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) REGEXP '^-?[0-9]+(\\\\.[0-9]+)?([eE][+-]?[0-9]+)?$', CAST(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) AS DECIMAL(19,4)), NULL)`;
399
+ } else if (field.type === "money") {
400
+ castExpr = `IF(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) REGEXP '^-?[0-9]+$', CAST(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) AS SIGNED), NULL)`;
373
401
  } else if (field.type === "boolean") {
374
402
  castExpr = `IF(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) IN ('true', '1'), 1, 0)`;
375
403
  } else if (field.type === "date" || field.type === "datetime") {
@@ -388,7 +416,7 @@ FIX INSTRUCTIONS:
388
416
  const existingIndexNames = new Set(existingIndicesRows.map((r) => r?.Key_name).filter(Boolean));
389
417
  for (const field of fields) {
390
418
  if (field.unique) {
391
- const idxName = `uniq_${tableName}_${field.name}`;
419
+ const idxName = (0, import_core.resolveIndexName)("uniq", tableName, [field.name]);
392
420
  if (!existingIndexNames.has(idxName) && !existingIndexNames.has(field.name) && !this.failedIndexes.has(idxName)) {
393
421
  try {
394
422
  const [colInfoRows] = await this.query(`SHOW COLUMNS FROM \`${tableName}\` WHERE Field = ?`, [field.name]);
@@ -420,7 +448,7 @@ FIX INSTRUCTIONS:
420
448
  for (const idx of indexes) {
421
449
  if (!Array.isArray(idx.fields) || idx.fields.length === 0) continue;
422
450
  const isUnique = Boolean(idx.unique);
423
- const idxName = idx.name || `${isUnique ? "uniq" : "idx"}_${tableName}_${idx.fields.join("_")}`;
451
+ const idxName = idx.name || (0, import_core.resolveIndexName)(isUnique ? "uniq" : "idx", tableName, idx.fields);
424
452
  if (!existingIndexNames.has(idxName) && !this.failedIndexes.has(idxName)) {
425
453
  for (const fName of idx.fields) {
426
454
  try {
@@ -472,7 +500,7 @@ FIX INSTRUCTIONS:
472
500
  let whereParams = [];
473
501
  if (args.where && Object.keys(args.where).length > 0) {
474
502
  const result = (0, import_core.parseSqlWhere)(
475
- args.where,
503
+ this.coerceBooleanWhere(args.collection, args.where),
476
504
  (field) => {
477
505
  if (field === "id") return "`id`";
478
506
  if (field === "createdAt" || field === "created_at") return "`created_at`";
@@ -480,7 +508,7 @@ FIX INSTRUCTIONS:
480
508
  if (existingCols.includes(field) && !["id", "data"].includes(field)) {
481
509
  return `\`${field}\``;
482
510
  }
483
- return `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field}'))`;
511
+ return jsonWhereExpr(field);
484
512
  },
485
513
  "?"
486
514
  );
@@ -498,12 +526,13 @@ FIX INSTRUCTIONS:
498
526
  `SELECT * FROM \`${tableName}\` ${whereSql} ORDER BY ${sort} LIMIT ? OFFSET ?${lock}`,
499
527
  [...whereParams, limit, offset]
500
528
  );
529
+ const dateCols = this.dateColumns(args.collection);
501
530
  const docs = rows.map((r) => {
502
531
  const parsedData = JSON.parse(typeof r.data === "string" ? r.data : JSON.stringify(r.data));
503
532
  const promotedOverlay = {};
504
533
  for (const col of existingCols) {
505
534
  if (!["id", "data", "created_at", "updated_at"].includes(col) && r[col] !== void 0 && r[col] !== null) {
506
- promotedOverlay[col] = r[col];
535
+ promotedOverlay[col] = dateCols.has(col) ? fromDatetimeColumn(r[col]) : r[col];
507
536
  }
508
537
  }
509
538
  return {
@@ -526,6 +555,11 @@ FIX INSTRUCTIONS:
526
555
  };
527
556
  }
528
557
  async findOne(params) {
558
+ if (params.id === void 0 && params.where) {
559
+ const found = await this.find({ collection: params.collection, where: params.where, limit: 1, lock: params.lock });
560
+ return found.docs[0] ?? null;
561
+ }
562
+ if (params.id === void 0) throw new Error("findOne requires either id or where");
529
563
  if (!this.inTransaction) await this.ensureTable(params.collection);
530
564
  const tableName = this.getTableName(params.collection);
531
565
  const lock = this.inTransaction || params.lock === "for-update" ? " FOR UPDATE" : "";
@@ -534,10 +568,11 @@ FIX INSTRUCTIONS:
534
568
  if (!row) return null;
535
569
  const parsedData = JSON.parse(typeof row.data === "string" ? row.data : JSON.stringify(row.data));
536
570
  const existingCols = await this.getTableColumns(tableName);
571
+ const dateCols = this.dateColumns(params.collection);
537
572
  const promotedOverlay = {};
538
573
  for (const col of existingCols) {
539
574
  if (!["id", "data", "created_at", "updated_at"].includes(col) && row[col] !== void 0 && row[col] !== null) {
540
- promotedOverlay[col] = row[col];
575
+ promotedOverlay[col] = dateCols.has(col) ? fromDatetimeColumn(row[col]) : row[col];
541
576
  }
542
577
  }
543
578
  return {
@@ -559,18 +594,19 @@ FIX INSTRUCTIONS:
559
594
  delete data.id;
560
595
  delete data.createdAt;
561
596
  delete data.updatedAt;
597
+ const dateCols = this.dateColumns(params.collection);
562
598
  const promotedValues = {};
563
599
  for (const col of existingCols) {
564
600
  if (["id", "data", "created_at", "updated_at"].includes(col)) continue;
565
601
  if (data[col] !== void 0) {
566
- promotedValues[col] = data[col];
602
+ promotedValues[col] = dateCols.has(col) ? toDatetimeColumn(data[col]) : data[col];
567
603
  }
568
604
  }
569
605
  const colNames = ["id", "data", "created_at", "updated_at", ...Object.keys(promotedValues).map((k) => `\`${k}\``)];
570
606
  const placeholders = colNames.map(() => "?").join(", ");
571
607
  const values = [id, JSON.stringify(data), now, now, ...Object.values(promotedValues)];
572
608
  await this.query(`INSERT INTO \`${tableName}\` (${colNames.join(", ")}) VALUES (${placeholders})`, values);
573
- return { id, ...data, ...promotedValues, createdAt: now, updatedAt: now };
609
+ return { id, ...data, ...promotedValues, ...this.isoDates(dateCols, promotedValues), createdAt: now, updatedAt: now };
574
610
  }
575
611
  async update(params) {
576
612
  if (!this.inTransaction) await this.ensureTable(params.collection);
@@ -584,7 +620,7 @@ FIX INSTRUCTIONS:
584
620
  whereParams = [params.id];
585
621
  if (params.where && Object.keys(params.where).length > 0) {
586
622
  const parsed = (0, import_core.parseSqlWhere)(
587
- params.where,
623
+ this.coerceBooleanWhere(params.collection, params.where),
588
624
  (field) => {
589
625
  if (field === "id") return "`id`";
590
626
  if (field === "createdAt" || field === "created_at") return "`created_at`";
@@ -592,7 +628,7 @@ FIX INSTRUCTIONS:
592
628
  if (existingCols.includes(field) && !["id", "data"].includes(field)) {
593
629
  return `\`${field}\``;
594
630
  }
595
- return `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field}'))`;
631
+ return jsonWhereExpr(field);
596
632
  },
597
633
  "?"
598
634
  );
@@ -601,7 +637,7 @@ FIX INSTRUCTIONS:
601
637
  }
602
638
  } else if (params.where && Object.keys(params.where).length > 0) {
603
639
  const parsed = (0, import_core.parseSqlWhere)(
604
- params.where,
640
+ this.coerceBooleanWhere(params.collection, params.where),
605
641
  (field) => {
606
642
  if (field === "id") return "`id`";
607
643
  if (field === "createdAt" || field === "created_at") return "`created_at`";
@@ -609,7 +645,7 @@ FIX INSTRUCTIONS:
609
645
  if (existingCols.includes(field) && !["id", "data"].includes(field)) {
610
646
  return `\`${field}\``;
611
647
  }
612
- return `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field}'))`;
648
+ return jsonWhereExpr(field);
613
649
  },
614
650
  "?"
615
651
  );
@@ -622,6 +658,7 @@ FIX INSTRUCTIONS:
622
658
  const setParams = [];
623
659
  const jsonPaths = [];
624
660
  const jsonParams = [];
661
+ const dateColsForUpdate = this.dateColumns(params.collection);
625
662
  for (const [key, val] of Object.entries(params.data)) {
626
663
  if (["id", "createdAt", "updatedAt"].includes(key)) continue;
627
664
  const isPromoted = existingCols.includes(key) && !["id", "data", "created_at", "updated_at"].includes(key);
@@ -640,7 +677,7 @@ FIX INSTRUCTIONS:
640
677
  if (isPromoted) {
641
678
  const escapedCol = escapeMysqlIdentifier(key);
642
679
  setClauses.push(`${escapedCol} = ?`);
643
- setParams.push(val);
680
+ setParams.push(dateColsForUpdate.has(key) ? toDatetimeColumn(val) : val);
644
681
  jsonPaths.push(`'$.${key}', ${escapedCol}`);
645
682
  } else if (val === null) {
646
683
  jsonPaths.push(`'$.${key}', CAST('null' AS JSON)`);
@@ -675,6 +712,21 @@ FIX INSTRUCTIONS:
675
712
  }
676
713
  return { id: targetId, ...params.data, affectedRows: res?.affectedRows ?? 0, updatedAt: now };
677
714
  }
715
+ /** Re-expresses bound date values as ISO strings for the document returned from a write. */
716
+ isoDates(dateCols, values) {
717
+ const out = {};
718
+ for (const col of dateCols) if (col in values) out[col] = fromDatetimeColumn(values[col]);
719
+ return out;
720
+ }
721
+ dateColumns(collection) {
722
+ return new Set(
723
+ (this.collectionConfigs.get(collection)?.fields ?? []).filter((f) => DATE_TYPES.has(f.type) && f.name).map((f) => f.name)
724
+ );
725
+ }
726
+ /** Applies {@link coerceBooleanWhere} using the collection's declared field types. */
727
+ coerceBooleanWhere(collection, where) {
728
+ return (0, import_core.coerceBooleanWhere)(where, this.collectionConfigs.get(collection)?.fields);
729
+ }
678
730
  async sync(collections) {
679
731
  for (const col of collections) {
680
732
  this.collectionConfigs.set(col.slug, col);
@@ -739,7 +791,7 @@ FIX INSTRUCTIONS:
739
791
  if (existingCols.includes(field) && !["id", "data"].includes(field)) {
740
792
  return `\`${field}\``;
741
793
  }
742
- return `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field}'))`;
794
+ return jsonWhereExpr(field);
743
795
  };
744
796
  const toCastExpr = (rawField, cast) => {
745
797
  const base = toFieldExpr(rawField);
@@ -749,13 +801,20 @@ FIX INSTRUCTIONS:
749
801
  const sqlType = cast === "integer" ? "SIGNED" : "DECIMAL(20,6)";
750
802
  return `IF(${base} REGEXP '^-?[0-9]+(\\\\.[0-9]+)?([eE][+-]?[0-9]+)?$', CAST(${base} AS ${sqlType}), NULL)`;
751
803
  };
804
+ const toDistinctValues = (raw) => {
805
+ const parsed = Array.isArray(raw) ? raw : typeof raw === "string" ? JSON.parse(raw) : raw ?? [];
806
+ if (!Array.isArray(parsed)) return [];
807
+ return Array.from(new Set(parsed.filter((v) => v !== null && v !== void 0)));
808
+ };
809
+ const groupFieldType = args.groupBy ? (this.collectionConfigs.get(args.collection)?.fields ?? []).find((f) => f.name === args.groupBy)?.type : void 0;
810
+ const groupKeyOf = (raw) => (0, import_core.normalizeGroupKey)(raw, groupFieldType);
752
811
  const selectParts = [];
753
812
  const allParams = [];
754
813
  const isDistinctMap = {};
755
814
  for (const [name, op] of Object.entries(args.aggregates)) {
756
815
  let whereSql = null;
757
816
  if (op.where && Object.keys(op.where).length > 0) {
758
- const parsed = (0, import_core.parseSqlWhere)(op.where, toFieldExpr, "?");
817
+ const parsed = (0, import_core.parseSqlWhere)(this.coerceBooleanWhere(args.collection, op.where), toFieldExpr, "?");
759
818
  whereSql = parsed.sql;
760
819
  allParams.push(...parsed.params);
761
820
  }
@@ -766,7 +825,7 @@ FIX INSTRUCTIONS:
766
825
  } else if ("distinct" in op && typeof op.distinct === "string") {
767
826
  isDistinctMap[name] = true;
768
827
  const fieldExpr = toFieldExpr(op.distinct);
769
- aggExpr = whereSql ? `COALESCE(JSON_ARRAYAGG(DISTINCT IF(${whereSql}, ${fieldExpr}, NULL)), JSON_ARRAY())` : `COALESCE(JSON_ARRAYAGG(DISTINCT ${fieldExpr}), JSON_ARRAY())`;
828
+ aggExpr = whereSql ? `COALESCE(JSON_ARRAYAGG(IF(${whereSql}, ${fieldExpr}, NULL)), JSON_ARRAY())` : `COALESCE(JSON_ARRAYAGG(${fieldExpr}), JSON_ARRAY())`;
770
829
  } else if ("count" in op) {
771
830
  aggExpr = whereSql ? `COUNT(IF(${whereSql}, 1, NULL))` : `COUNT(*)`;
772
831
  } else if (op.sum) {
@@ -774,7 +833,7 @@ FIX INSTRUCTIONS:
774
833
  aggExpr = whereSql ? `SUM(IF(${whereSql}, ${val}, NULL))` : `SUM(${val})`;
775
834
  } else if (op.avg) {
776
835
  const val = toCastExpr(op.avg, op.cast);
777
- aggExpr = whereSql ? `AVG(IF(${whereSql}, ${val}, NULL))` : `AVG(${val})`;
836
+ aggExpr = whereSql ? `CAST(AVG(IF(${whereSql}, ${val}, NULL)) AS DOUBLE)` : `CAST(AVG(${val}) AS DOUBLE)`;
778
837
  } else if (op.min) {
779
838
  const val = toCastExpr(op.min, op.cast);
780
839
  aggExpr = whereSql ? `MIN(IF(${whereSql}, ${val}, NULL))` : `MIN(${val})`;
@@ -794,13 +853,12 @@ FIX INSTRUCTIONS:
794
853
  const [rows2] = await this.query(query2, allParams);
795
854
  const groups = {};
796
855
  for (const row2 of rows2 ?? []) {
797
- const key = row2.__group_key === null || row2.__group_key === void 0 ? "__unassigned__" : String(row2.__group_key);
856
+ const key = groupKeyOf(row2.__group_key);
798
857
  const groupResult = {};
799
858
  for (const name of Object.keys(args.aggregates)) {
800
859
  const raw = row2[name];
801
860
  if (isDistinctMap[name]) {
802
- const parsed = Array.isArray(raw) ? raw : typeof raw === "string" ? JSON.parse(raw) : raw ?? [];
803
- groupResult[name] = Array.isArray(parsed) ? parsed.filter((v) => v !== null && v !== void 0) : [];
861
+ groupResult[name] = toDistinctValues(raw);
804
862
  } else {
805
863
  groupResult[name] = raw === null || raw === void 0 ? null : Number(raw);
806
864
  }
@@ -817,8 +875,7 @@ FIX INSTRUCTIONS:
817
875
  const raw = row[name];
818
876
  const op = args.aggregates[name];
819
877
  if (isDistinctMap[name]) {
820
- const parsed = Array.isArray(raw) ? raw : typeof raw === "string" ? JSON.parse(raw) : raw ?? [];
821
- result[name] = Array.isArray(parsed) ? parsed.filter((v) => v !== null && v !== void 0) : [];
878
+ result[name] = toDistinctValues(raw);
822
879
  } else if (raw instanceof Date) {
823
880
  result[name] = raw.toISOString();
824
881
  } else if (op?.cast === "date") {
package/dist/index.d.cts CHANGED
@@ -44,7 +44,8 @@ declare class MysqlAdapter implements DatabaseAdapter {
44
44
  }): Promise<PaginatedResult>;
45
45
  findOne(params: {
46
46
  collection: string;
47
- id: string;
47
+ id?: string;
48
+ where?: Record<string, unknown>;
48
49
  lock?: "for-update";
49
50
  }): Promise<any>;
50
51
  create(params: {
@@ -57,6 +58,11 @@ declare class MysqlAdapter implements DatabaseAdapter {
57
58
  where?: any;
58
59
  data: any;
59
60
  }): Promise<any>;
61
+ /** Re-expresses bound date values as ISO strings for the document returned from a write. */
62
+ private isoDates;
63
+ private dateColumns;
64
+ /** Applies {@link coerceBooleanWhere} using the collection's declared field types. */
65
+ private coerceBooleanWhere;
60
66
  sync(collections: any[]): Promise<void>;
61
67
  delete(params: {
62
68
  collection: string;
package/dist/index.d.ts CHANGED
@@ -44,7 +44,8 @@ declare class MysqlAdapter implements DatabaseAdapter {
44
44
  }): Promise<PaginatedResult>;
45
45
  findOne(params: {
46
46
  collection: string;
47
- id: string;
47
+ id?: string;
48
+ where?: Record<string, unknown>;
48
49
  lock?: "for-update";
49
50
  }): Promise<any>;
50
51
  create(params: {
@@ -57,6 +58,11 @@ declare class MysqlAdapter implements DatabaseAdapter {
57
58
  where?: any;
58
59
  data: any;
59
60
  }): Promise<any>;
61
+ /** Re-expresses bound date values as ISO strings for the document returned from a write. */
62
+ private isoDates;
63
+ private dateColumns;
64
+ /** Applies {@link coerceBooleanWhere} using the collection's declared field types. */
65
+ private coerceBooleanWhere;
60
66
  sync(collections: any[]): Promise<void>;
61
67
  delete(params: {
62
68
  collection: string;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import { parseSort, parseSqlWhere, DuplicateKeyError, generateDocumentId } from "@dyrected/core";
2
+ import { parseSort, parseSqlWhere, DuplicateKeyError, generateDocumentId, resolveIndexName, coerceBooleanWhere, normalizeGroupKey } from "@dyrected/core";
3
3
  import mysql from "mysql2/promise";
4
4
  function parseMysqlDuplicateKey(message) {
5
5
  const match = message.match(/Duplicate entry '(.*)' for key '(?:[^.]*\.)?(.*)'/);
@@ -14,6 +14,24 @@ function parseMysqlDuplicateKey(message) {
14
14
  }
15
15
  return { field, value };
16
16
  }
17
+ function jsonWhereExpr(field) {
18
+ const path = `JSON_EXTRACT(data, '$.${field}')`;
19
+ return `IF(JSON_TYPE(${path}) = 'NULL', NULL, IF(JSON_TYPE(${path}) = 'BOOLEAN', IF(${path} = TRUE, 1, 0), JSON_UNQUOTE(${path})))`;
20
+ }
21
+ var DATE_TYPES = /* @__PURE__ */ new Set(["date", "datetime"]);
22
+ var ISO_WITH_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/;
23
+ var MYSQL_DATETIME = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)?$/;
24
+ function toDatetimeColumn(value) {
25
+ if (typeof value !== "string" || !ISO_WITH_TIME.test(value)) return value;
26
+ const date = new Date(value);
27
+ if (Number.isNaN(date.getTime())) return value;
28
+ return date.toISOString().replace("T", " ").replace("Z", "");
29
+ }
30
+ function fromDatetimeColumn(value) {
31
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? value : value.toISOString();
32
+ if (typeof value === "string" && MYSQL_DATETIME.test(value)) return `${value.replace(" ", "T")}Z`;
33
+ return value;
34
+ }
17
35
  function isNumericOp(val) {
18
36
  return val !== null && typeof val === "object" && !Array.isArray(val) && ("increment" in val || "decrement" in val);
19
37
  }
@@ -316,7 +334,15 @@ FIX INSTRUCTIONS:
316
334
  console.log(`[dyrected/mysql] Promoting field "${field.name}" to column in ${tableName}`);
317
335
  const isUniqueOrIndexed = field.unique || indexedFields.has(field.name);
318
336
  let sqlType = isUniqueOrIndexed ? "VARCHAR(191)" : "TEXT";
337
+ if (!isUniqueOrIndexed) {
338
+ if ((field.type === "select" || field.type === "radio") && !field.hasMany) sqlType = "VARCHAR(100)";
339
+ else if (field.type === "relationship" && !field.hasMany) sqlType = "VARCHAR(191)";
340
+ else if (field.type === "email") sqlType = "VARCHAR(254)";
341
+ } else if (field.type === "email") {
342
+ sqlType = "VARCHAR(191)";
343
+ }
319
344
  if (field.type === "number") sqlType = "DECIMAL(19,4)";
345
+ if (field.type === "money") sqlType = "BIGINT";
320
346
  if (field.type === "boolean") sqlType = "TINYINT(1)";
321
347
  if (field.type === "date" || field.type === "datetime") sqlType = "DATETIME(3)";
322
348
  if (field.type === "textarea" || field.type === "richText" || field.type === "json") sqlType = "LONGTEXT";
@@ -334,6 +360,8 @@ FIX INSTRUCTIONS:
334
360
  let castExpr = `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}'))`;
335
361
  if (field.type === "number") {
336
362
  castExpr = `IF(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) REGEXP '^-?[0-9]+(\\\\.[0-9]+)?([eE][+-]?[0-9]+)?$', CAST(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) AS DECIMAL(19,4)), NULL)`;
363
+ } else if (field.type === "money") {
364
+ castExpr = `IF(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) REGEXP '^-?[0-9]+$', CAST(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) AS SIGNED), NULL)`;
337
365
  } else if (field.type === "boolean") {
338
366
  castExpr = `IF(JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field.name}')) IN ('true', '1'), 1, 0)`;
339
367
  } else if (field.type === "date" || field.type === "datetime") {
@@ -352,7 +380,7 @@ FIX INSTRUCTIONS:
352
380
  const existingIndexNames = new Set(existingIndicesRows.map((r) => r?.Key_name).filter(Boolean));
353
381
  for (const field of fields) {
354
382
  if (field.unique) {
355
- const idxName = `uniq_${tableName}_${field.name}`;
383
+ const idxName = resolveIndexName("uniq", tableName, [field.name]);
356
384
  if (!existingIndexNames.has(idxName) && !existingIndexNames.has(field.name) && !this.failedIndexes.has(idxName)) {
357
385
  try {
358
386
  const [colInfoRows] = await this.query(`SHOW COLUMNS FROM \`${tableName}\` WHERE Field = ?`, [field.name]);
@@ -384,7 +412,7 @@ FIX INSTRUCTIONS:
384
412
  for (const idx of indexes) {
385
413
  if (!Array.isArray(idx.fields) || idx.fields.length === 0) continue;
386
414
  const isUnique = Boolean(idx.unique);
387
- const idxName = idx.name || `${isUnique ? "uniq" : "idx"}_${tableName}_${idx.fields.join("_")}`;
415
+ const idxName = idx.name || resolveIndexName(isUnique ? "uniq" : "idx", tableName, idx.fields);
388
416
  if (!existingIndexNames.has(idxName) && !this.failedIndexes.has(idxName)) {
389
417
  for (const fName of idx.fields) {
390
418
  try {
@@ -436,7 +464,7 @@ FIX INSTRUCTIONS:
436
464
  let whereParams = [];
437
465
  if (args.where && Object.keys(args.where).length > 0) {
438
466
  const result = parseSqlWhere(
439
- args.where,
467
+ this.coerceBooleanWhere(args.collection, args.where),
440
468
  (field) => {
441
469
  if (field === "id") return "`id`";
442
470
  if (field === "createdAt" || field === "created_at") return "`created_at`";
@@ -444,7 +472,7 @@ FIX INSTRUCTIONS:
444
472
  if (existingCols.includes(field) && !["id", "data"].includes(field)) {
445
473
  return `\`${field}\``;
446
474
  }
447
- return `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field}'))`;
475
+ return jsonWhereExpr(field);
448
476
  },
449
477
  "?"
450
478
  );
@@ -462,12 +490,13 @@ FIX INSTRUCTIONS:
462
490
  `SELECT * FROM \`${tableName}\` ${whereSql} ORDER BY ${sort} LIMIT ? OFFSET ?${lock}`,
463
491
  [...whereParams, limit, offset]
464
492
  );
493
+ const dateCols = this.dateColumns(args.collection);
465
494
  const docs = rows.map((r) => {
466
495
  const parsedData = JSON.parse(typeof r.data === "string" ? r.data : JSON.stringify(r.data));
467
496
  const promotedOverlay = {};
468
497
  for (const col of existingCols) {
469
498
  if (!["id", "data", "created_at", "updated_at"].includes(col) && r[col] !== void 0 && r[col] !== null) {
470
- promotedOverlay[col] = r[col];
499
+ promotedOverlay[col] = dateCols.has(col) ? fromDatetimeColumn(r[col]) : r[col];
471
500
  }
472
501
  }
473
502
  return {
@@ -490,6 +519,11 @@ FIX INSTRUCTIONS:
490
519
  };
491
520
  }
492
521
  async findOne(params) {
522
+ if (params.id === void 0 && params.where) {
523
+ const found = await this.find({ collection: params.collection, where: params.where, limit: 1, lock: params.lock });
524
+ return found.docs[0] ?? null;
525
+ }
526
+ if (params.id === void 0) throw new Error("findOne requires either id or where");
493
527
  if (!this.inTransaction) await this.ensureTable(params.collection);
494
528
  const tableName = this.getTableName(params.collection);
495
529
  const lock = this.inTransaction || params.lock === "for-update" ? " FOR UPDATE" : "";
@@ -498,10 +532,11 @@ FIX INSTRUCTIONS:
498
532
  if (!row) return null;
499
533
  const parsedData = JSON.parse(typeof row.data === "string" ? row.data : JSON.stringify(row.data));
500
534
  const existingCols = await this.getTableColumns(tableName);
535
+ const dateCols = this.dateColumns(params.collection);
501
536
  const promotedOverlay = {};
502
537
  for (const col of existingCols) {
503
538
  if (!["id", "data", "created_at", "updated_at"].includes(col) && row[col] !== void 0 && row[col] !== null) {
504
- promotedOverlay[col] = row[col];
539
+ promotedOverlay[col] = dateCols.has(col) ? fromDatetimeColumn(row[col]) : row[col];
505
540
  }
506
541
  }
507
542
  return {
@@ -523,18 +558,19 @@ FIX INSTRUCTIONS:
523
558
  delete data.id;
524
559
  delete data.createdAt;
525
560
  delete data.updatedAt;
561
+ const dateCols = this.dateColumns(params.collection);
526
562
  const promotedValues = {};
527
563
  for (const col of existingCols) {
528
564
  if (["id", "data", "created_at", "updated_at"].includes(col)) continue;
529
565
  if (data[col] !== void 0) {
530
- promotedValues[col] = data[col];
566
+ promotedValues[col] = dateCols.has(col) ? toDatetimeColumn(data[col]) : data[col];
531
567
  }
532
568
  }
533
569
  const colNames = ["id", "data", "created_at", "updated_at", ...Object.keys(promotedValues).map((k) => `\`${k}\``)];
534
570
  const placeholders = colNames.map(() => "?").join(", ");
535
571
  const values = [id, JSON.stringify(data), now, now, ...Object.values(promotedValues)];
536
572
  await this.query(`INSERT INTO \`${tableName}\` (${colNames.join(", ")}) VALUES (${placeholders})`, values);
537
- return { id, ...data, ...promotedValues, createdAt: now, updatedAt: now };
573
+ return { id, ...data, ...promotedValues, ...this.isoDates(dateCols, promotedValues), createdAt: now, updatedAt: now };
538
574
  }
539
575
  async update(params) {
540
576
  if (!this.inTransaction) await this.ensureTable(params.collection);
@@ -548,7 +584,7 @@ FIX INSTRUCTIONS:
548
584
  whereParams = [params.id];
549
585
  if (params.where && Object.keys(params.where).length > 0) {
550
586
  const parsed = parseSqlWhere(
551
- params.where,
587
+ this.coerceBooleanWhere(params.collection, params.where),
552
588
  (field) => {
553
589
  if (field === "id") return "`id`";
554
590
  if (field === "createdAt" || field === "created_at") return "`created_at`";
@@ -556,7 +592,7 @@ FIX INSTRUCTIONS:
556
592
  if (existingCols.includes(field) && !["id", "data"].includes(field)) {
557
593
  return `\`${field}\``;
558
594
  }
559
- return `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field}'))`;
595
+ return jsonWhereExpr(field);
560
596
  },
561
597
  "?"
562
598
  );
@@ -565,7 +601,7 @@ FIX INSTRUCTIONS:
565
601
  }
566
602
  } else if (params.where && Object.keys(params.where).length > 0) {
567
603
  const parsed = parseSqlWhere(
568
- params.where,
604
+ this.coerceBooleanWhere(params.collection, params.where),
569
605
  (field) => {
570
606
  if (field === "id") return "`id`";
571
607
  if (field === "createdAt" || field === "created_at") return "`created_at`";
@@ -573,7 +609,7 @@ FIX INSTRUCTIONS:
573
609
  if (existingCols.includes(field) && !["id", "data"].includes(field)) {
574
610
  return `\`${field}\``;
575
611
  }
576
- return `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field}'))`;
612
+ return jsonWhereExpr(field);
577
613
  },
578
614
  "?"
579
615
  );
@@ -586,6 +622,7 @@ FIX INSTRUCTIONS:
586
622
  const setParams = [];
587
623
  const jsonPaths = [];
588
624
  const jsonParams = [];
625
+ const dateColsForUpdate = this.dateColumns(params.collection);
589
626
  for (const [key, val] of Object.entries(params.data)) {
590
627
  if (["id", "createdAt", "updatedAt"].includes(key)) continue;
591
628
  const isPromoted = existingCols.includes(key) && !["id", "data", "created_at", "updated_at"].includes(key);
@@ -604,7 +641,7 @@ FIX INSTRUCTIONS:
604
641
  if (isPromoted) {
605
642
  const escapedCol = escapeMysqlIdentifier(key);
606
643
  setClauses.push(`${escapedCol} = ?`);
607
- setParams.push(val);
644
+ setParams.push(dateColsForUpdate.has(key) ? toDatetimeColumn(val) : val);
608
645
  jsonPaths.push(`'$.${key}', ${escapedCol}`);
609
646
  } else if (val === null) {
610
647
  jsonPaths.push(`'$.${key}', CAST('null' AS JSON)`);
@@ -639,6 +676,21 @@ FIX INSTRUCTIONS:
639
676
  }
640
677
  return { id: targetId, ...params.data, affectedRows: res?.affectedRows ?? 0, updatedAt: now };
641
678
  }
679
+ /** Re-expresses bound date values as ISO strings for the document returned from a write. */
680
+ isoDates(dateCols, values) {
681
+ const out = {};
682
+ for (const col of dateCols) if (col in values) out[col] = fromDatetimeColumn(values[col]);
683
+ return out;
684
+ }
685
+ dateColumns(collection) {
686
+ return new Set(
687
+ (this.collectionConfigs.get(collection)?.fields ?? []).filter((f) => DATE_TYPES.has(f.type) && f.name).map((f) => f.name)
688
+ );
689
+ }
690
+ /** Applies {@link coerceBooleanWhere} using the collection's declared field types. */
691
+ coerceBooleanWhere(collection, where) {
692
+ return coerceBooleanWhere(where, this.collectionConfigs.get(collection)?.fields);
693
+ }
642
694
  async sync(collections) {
643
695
  for (const col of collections) {
644
696
  this.collectionConfigs.set(col.slug, col);
@@ -703,7 +755,7 @@ FIX INSTRUCTIONS:
703
755
  if (existingCols.includes(field) && !["id", "data"].includes(field)) {
704
756
  return `\`${field}\``;
705
757
  }
706
- return `JSON_UNQUOTE(JSON_EXTRACT(data, '$.${field}'))`;
758
+ return jsonWhereExpr(field);
707
759
  };
708
760
  const toCastExpr = (rawField, cast) => {
709
761
  const base = toFieldExpr(rawField);
@@ -713,13 +765,20 @@ FIX INSTRUCTIONS:
713
765
  const sqlType = cast === "integer" ? "SIGNED" : "DECIMAL(20,6)";
714
766
  return `IF(${base} REGEXP '^-?[0-9]+(\\\\.[0-9]+)?([eE][+-]?[0-9]+)?$', CAST(${base} AS ${sqlType}), NULL)`;
715
767
  };
768
+ const toDistinctValues = (raw) => {
769
+ const parsed = Array.isArray(raw) ? raw : typeof raw === "string" ? JSON.parse(raw) : raw ?? [];
770
+ if (!Array.isArray(parsed)) return [];
771
+ return Array.from(new Set(parsed.filter((v) => v !== null && v !== void 0)));
772
+ };
773
+ const groupFieldType = args.groupBy ? (this.collectionConfigs.get(args.collection)?.fields ?? []).find((f) => f.name === args.groupBy)?.type : void 0;
774
+ const groupKeyOf = (raw) => normalizeGroupKey(raw, groupFieldType);
716
775
  const selectParts = [];
717
776
  const allParams = [];
718
777
  const isDistinctMap = {};
719
778
  for (const [name, op] of Object.entries(args.aggregates)) {
720
779
  let whereSql = null;
721
780
  if (op.where && Object.keys(op.where).length > 0) {
722
- const parsed = parseSqlWhere(op.where, toFieldExpr, "?");
781
+ const parsed = parseSqlWhere(this.coerceBooleanWhere(args.collection, op.where), toFieldExpr, "?");
723
782
  whereSql = parsed.sql;
724
783
  allParams.push(...parsed.params);
725
784
  }
@@ -730,7 +789,7 @@ FIX INSTRUCTIONS:
730
789
  } else if ("distinct" in op && typeof op.distinct === "string") {
731
790
  isDistinctMap[name] = true;
732
791
  const fieldExpr = toFieldExpr(op.distinct);
733
- aggExpr = whereSql ? `COALESCE(JSON_ARRAYAGG(DISTINCT IF(${whereSql}, ${fieldExpr}, NULL)), JSON_ARRAY())` : `COALESCE(JSON_ARRAYAGG(DISTINCT ${fieldExpr}), JSON_ARRAY())`;
792
+ aggExpr = whereSql ? `COALESCE(JSON_ARRAYAGG(IF(${whereSql}, ${fieldExpr}, NULL)), JSON_ARRAY())` : `COALESCE(JSON_ARRAYAGG(${fieldExpr}), JSON_ARRAY())`;
734
793
  } else if ("count" in op) {
735
794
  aggExpr = whereSql ? `COUNT(IF(${whereSql}, 1, NULL))` : `COUNT(*)`;
736
795
  } else if (op.sum) {
@@ -738,7 +797,7 @@ FIX INSTRUCTIONS:
738
797
  aggExpr = whereSql ? `SUM(IF(${whereSql}, ${val}, NULL))` : `SUM(${val})`;
739
798
  } else if (op.avg) {
740
799
  const val = toCastExpr(op.avg, op.cast);
741
- aggExpr = whereSql ? `AVG(IF(${whereSql}, ${val}, NULL))` : `AVG(${val})`;
800
+ aggExpr = whereSql ? `CAST(AVG(IF(${whereSql}, ${val}, NULL)) AS DOUBLE)` : `CAST(AVG(${val}) AS DOUBLE)`;
742
801
  } else if (op.min) {
743
802
  const val = toCastExpr(op.min, op.cast);
744
803
  aggExpr = whereSql ? `MIN(IF(${whereSql}, ${val}, NULL))` : `MIN(${val})`;
@@ -758,13 +817,12 @@ FIX INSTRUCTIONS:
758
817
  const [rows2] = await this.query(query2, allParams);
759
818
  const groups = {};
760
819
  for (const row2 of rows2 ?? []) {
761
- const key = row2.__group_key === null || row2.__group_key === void 0 ? "__unassigned__" : String(row2.__group_key);
820
+ const key = groupKeyOf(row2.__group_key);
762
821
  const groupResult = {};
763
822
  for (const name of Object.keys(args.aggregates)) {
764
823
  const raw = row2[name];
765
824
  if (isDistinctMap[name]) {
766
- const parsed = Array.isArray(raw) ? raw : typeof raw === "string" ? JSON.parse(raw) : raw ?? [];
767
- groupResult[name] = Array.isArray(parsed) ? parsed.filter((v) => v !== null && v !== void 0) : [];
825
+ groupResult[name] = toDistinctValues(raw);
768
826
  } else {
769
827
  groupResult[name] = raw === null || raw === void 0 ? null : Number(raw);
770
828
  }
@@ -781,8 +839,7 @@ FIX INSTRUCTIONS:
781
839
  const raw = row[name];
782
840
  const op = args.aggregates[name];
783
841
  if (isDistinctMap[name]) {
784
- const parsed = Array.isArray(raw) ? raw : typeof raw === "string" ? JSON.parse(raw) : raw ?? [];
785
- result[name] = Array.isArray(parsed) ? parsed.filter((v) => v !== null && v !== void 0) : [];
842
+ result[name] = toDistinctValues(raw);
786
843
  } else if (raw instanceof Date) {
787
844
  result[name] = raw.toISOString();
788
845
  } else if (op?.cast === "date") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dyrected/db-mysql",
3
- "version": "2.17.0",
3
+ "version": "2.18.1",
4
4
  "description": "MySQL adapter for Dyrected CMS",
5
5
  "type": "module",
6
6
  "files": [
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "mysql2": "^3.22.3",
21
- "@dyrected/core": "^2.17.0"
21
+ "@dyrected/core": "^2.18.1"
22
22
  },
23
23
  "devDependencies": {
24
24
  "tsup": "^8.0.0",