@drzl/validation-core 3.12.0 → 3.14.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.cjs CHANGED
@@ -84460,6 +84460,7 @@ __export(index_exports2, {
84460
84460
  moduleSpecifier: () => moduleSpecifier,
84461
84461
  parseCheck: () => parseCheck,
84462
84462
  pascalCase: () => pascalCase,
84463
+ renderDuplicateFinder: () => renderDuplicateFinder,
84463
84464
  resolveAffix: () => resolveAffix,
84464
84465
  resolveConfiguredImport: () => resolveConfiguredImport,
84465
84466
  schemaName: () => schemaName,
@@ -84473,6 +84474,7 @@ module.exports = __toCommonJS(index_exports2);
84473
84474
  // src/checks.ts
84474
84475
  var COMPARISON = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(>=|<=|<>|!=|>|<|=)\s*(.+?)\s*$/;
84475
84476
  var IN_LIST = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s+IN\s*\((.+)\)\s*$/i;
84477
+ var CARDINALITY_OF = /^\s*(?:cardinality\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)|array_length\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*1\s*\))\s*(>=|<=|<>|!=|>|<|=)\s*(\d+)\s*$/i;
84476
84478
  var LENGTH_OF = /^\s*(?:length|char_length)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)\s*(>=|<=|<>|!=|>|<|=)\s*(\d+)\s*$/i;
84477
84479
  function splitTopLevelAnd(expr) {
84478
84480
  const parts = [];
@@ -84595,6 +84597,7 @@ function parseCheck(expression, name) {
84595
84597
  const sets = [];
84596
84598
  const rows = [];
84597
84599
  const lengths = [];
84600
+ const cardinalities = [];
84598
84601
  for (const part of parts) {
84599
84602
  const parsed = parseCheck(part, name);
84600
84603
  if (!parsed.ok)
@@ -84603,13 +84606,15 @@ function parseCheck(expression, name) {
84603
84606
  if (parsed.sets) sets.push(...parsed.sets);
84604
84607
  if (parsed.rows) rows.push(...parsed.rows);
84605
84608
  if (parsed.lengths) lengths.push(...parsed.lengths);
84609
+ if (parsed.cardinalities) cardinalities.push(...parsed.cardinalities);
84606
84610
  }
84607
84611
  return {
84608
84612
  ok: true,
84609
84613
  checks,
84610
84614
  ...sets.length ? { sets } : {},
84611
84615
  ...rows.length ? { rows } : {},
84612
- ...lengths.length ? { lengths } : {}
84616
+ ...lengths.length ? { lengths } : {},
84617
+ ...cardinalities.length ? { cardinalities } : {}
84613
84618
  };
84614
84619
  }
84615
84620
  const lengthOf = expr.match(LENGTH_OF);
@@ -84621,6 +84626,22 @@ function parseCheck(expression, name) {
84621
84626
  lengths: [{ column: lengthOf[1], operator: op4, value: lengthOf[3], name }]
84622
84627
  };
84623
84628
  }
84629
+ const cardinalityOf = expr.match(CARDINALITY_OF);
84630
+ if (cardinalityOf) {
84631
+ const op4 = cardinalityOf[3] === "!=" ? "<>" : cardinalityOf[3];
84632
+ return {
84633
+ ok: true,
84634
+ checks: [],
84635
+ cardinalities: [
84636
+ {
84637
+ column: cardinalityOf[1] ?? cardinalityOf[2],
84638
+ operator: op4,
84639
+ value: cardinalityOf[4],
84640
+ ...name ? { name } : {}
84641
+ }
84642
+ ]
84643
+ };
84644
+ }
84624
84645
  const inList = expr.match(IN_LIST);
84625
84646
  if (inList) {
84626
84647
  const raw = splitTopLevelCommas(inList[2]);
@@ -84829,6 +84850,54 @@ function validateAffix(affix, schemaSuffix) {
84829
84850
  return issues;
84830
84851
  }
84831
84852
 
84853
+ // src/duplicates.ts
84854
+ function usableKeys(table) {
84855
+ return (table.unique ?? []).filter((k5) => k5.columns.length > 0);
84856
+ }
84857
+ function renderDuplicateFinder(table, fnName, rowType) {
84858
+ const keys = usableKeys(table);
84859
+ if (!keys.length) return void 0;
84860
+ const constraints = keys.map((k5, i) => {
84861
+ const name = k5.name ?? k5.columns.join("_");
84862
+ return ` { name: ${JSON.stringify(name)}, columns: ${JSON.stringify(k5.columns)} }${i === keys.length - 1 ? "" : ","}`;
84863
+ }).join("\n");
84864
+ return `/**
84865
+ * Rows in \`rows\` that collide with an earlier row on a unique constraint.
84866
+ *
84867
+ * Uniqueness is a fact about the table rather than about a row, so no schema can check it. This
84868
+ * checks the half that needs no database: whether the batch collides with itself. A batch that
84869
+ * passes here can still collide with rows already stored.
84870
+ *
84871
+ * A constraint is skipped for any row where one of its columns is null or absent, matching SQL,
84872
+ * where NULL is not equal to NULL and a unique index therefore permits repeats.
84873
+ */
84874
+ export function ${fnName}(
84875
+ rows: readonly ${rowType}[]
84876
+ ): Array<{ index: number; constraint: string; firstIndex: number }> {
84877
+ const constraints = [
84878
+ ${constraints}
84879
+ ] as const;
84880
+ const seen = constraints.map(() => new Map<string, number>());
84881
+ const out: Array<{ index: number; constraint: string; firstIndex: number }> = [];
84882
+
84883
+ for (let i = 0; i < rows.length; i++) {
84884
+ const row = rows[i] as Record<string, unknown>;
84885
+ for (let c = 0; c < constraints.length; c++) {
84886
+ const cols = constraints[c].columns;
84887
+ const values = cols.map((col) => row?.[col]);
84888
+ if (values.some((v) => v === null || v === undefined)) continue;
84889
+ // JSON, so a composite key compares by value and \`[1, "2"]\` never collides with
84890
+ // \`["1", 2]\`. A join on a separator would.
84891
+ const key = JSON.stringify(values);
84892
+ const first = seen[c].get(key);
84893
+ if (first === undefined) seen[c].set(key, i);
84894
+ else out.push({ index: i, constraint: constraints[c].name, firstIndex: first });
84895
+ }
84896
+ }
84897
+ return out;
84898
+ }`;
84899
+ }
84900
+
84832
84901
  // src/index.ts
84833
84902
  function isGeneratedColumn(c5, _primaryKeyColumns = []) {
84834
84903
  return c5.isGenerated;
@@ -84901,6 +84970,7 @@ async function formatCode(code, filePath, fmt) {
84901
84970
  moduleSpecifier,
84902
84971
  parseCheck,
84903
84972
  pascalCase,
84973
+ renderDuplicateFinder,
84904
84974
  resolveAffix,
84905
84975
  resolveConfiguredImport,
84906
84976
  schemaName,
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { Column, Analysis } from '@drzl/analyzer';
1
+ import { Table as Table$1, Column, Analysis } from '@drzl/analyzer';
2
2
 
3
3
  /**
4
4
  * One place that decides what a generated module is called, on disk and in an import.
@@ -244,6 +244,18 @@ interface LengthCheck {
244
244
  value: string;
245
245
  name?: string;
246
246
  }
247
+ /**
248
+ * A constraint on an array column's element count, from `CHECK (cardinality(tags) > 0)`.
249
+ *
250
+ * The array analogue of `LengthCheck`, and free of the question that one carries: an element
251
+ * count is the same number in SQL and in JavaScript, with no encoding involved.
252
+ */
253
+ interface CardinalityCheck {
254
+ column: string;
255
+ operator: ColumnCheck['operator'];
256
+ value: string;
257
+ name?: string;
258
+ }
247
259
  /** A check that was understood, or the reason it was not. */
248
260
  type ParsedCheck = {
249
261
  ok: true;
@@ -251,6 +263,7 @@ type ParsedCheck = {
251
263
  sets?: ColumnSet[];
252
264
  rows?: RowCheck[];
253
265
  lengths?: LengthCheck[];
266
+ cardinalities?: CardinalityCheck[];
254
267
  } | {
255
268
  ok: false;
256
269
  reason: string;
@@ -271,6 +284,30 @@ declare function parseCheck(expression: string | undefined, name?: string): Pars
271
284
  */
272
285
  declare function describeSet(set: ColumnSet): string;
273
286
 
287
+ /**
288
+ * A duplicate finder for a batch of rows, emitted beside the schemas.
289
+ *
290
+ * A validator checks one row at a time, so uniqueness is the one constraint it structurally
291
+ * cannot see: whether a value is unique is a fact about the table, not about the row. That is
292
+ * fine for a single insert, where the database answers immediately. It is not fine for a bulk
293
+ * insert, where a batch of a thousand rows fails whole on one collision and the error names a
294
+ * constraint rather than a row.
295
+ *
296
+ * What *is* checkable without the database is whether a batch collides with **itself**, and that
297
+ * is worth checking, because it is the half a user can fix before sending anything.
298
+ *
299
+ * The emitted function is plain TypeScript with no reference to any validation library, so all
300
+ * four generators emit the same thing. It is rendered from one place for that reason.
301
+ */
302
+
303
+ /**
304
+ * `findDuplicate<Table>s` for a table with unique constraints, or nothing.
305
+ *
306
+ * `rowType` is the name of the insert type, which is what a caller has in hand before an insert.
307
+ * Passed in rather than derived, because each generator names its types differently.
308
+ */
309
+ declare function renderDuplicateFinder(table: Table$1, fnName: string, rowType: string): string | undefined;
310
+
274
311
  interface Table {
275
312
  name: string;
276
313
  tsName: string;
@@ -448,4 +485,4 @@ declare function updateColumns(table: Table): Column[];
448
485
  declare function selectColumns(table: Table): Column[];
449
486
  declare function formatCode(code: string, filePath: string, fmt?: FormatOptions): Promise<any>;
450
487
 
451
- export { AFFIX_PROBE_TABLE, type AffixIssue, type AffixOptions, type AffixValue, CODEPOINT_LENGTH, COLUMN_FORMATS, type ColumnCheck, type ColumnSet, DEFAULT_IMPORT_EXTENSION, DEFAULT_MODE_PREFIX, DEFAULT_SCHEMA_SUFFIX, DEFAULT_TYPE_SUFFIX, type FormatOptions, IMPORT_EXTENSIONS, type ImportExtension, type LengthCheck, NAME_MODES, type NameMode, type ParsedCheck, type ResolvedAffix, type RowCheck, type Table, type TableCase, type ValidationGenerateOptions, type ValidationLibrary, type ValidationRenderer, applyTableCase, describeSet, formatCode, importSpecifier, insertColumns, isGeneratedColumn, isIntegerColumn, moduleFileName, moduleSpecifier, parseCheck, pascalCase, resolveAffix, resolveConfiguredImport, schemaName, selectColumns, typeName, updateColumns, validateAffix };
488
+ export { AFFIX_PROBE_TABLE, type AffixIssue, type AffixOptions, type AffixValue, CODEPOINT_LENGTH, COLUMN_FORMATS, type CardinalityCheck, type ColumnCheck, type ColumnSet, DEFAULT_IMPORT_EXTENSION, DEFAULT_MODE_PREFIX, DEFAULT_SCHEMA_SUFFIX, DEFAULT_TYPE_SUFFIX, type FormatOptions, IMPORT_EXTENSIONS, type ImportExtension, type LengthCheck, NAME_MODES, type NameMode, type ParsedCheck, type ResolvedAffix, type RowCheck, type Table, type TableCase, type ValidationGenerateOptions, type ValidationLibrary, type ValidationRenderer, applyTableCase, describeSet, formatCode, importSpecifier, insertColumns, isGeneratedColumn, isIntegerColumn, moduleFileName, moduleSpecifier, parseCheck, pascalCase, renderDuplicateFinder, resolveAffix, resolveConfiguredImport, schemaName, selectColumns, typeName, updateColumns, validateAffix };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Column, Analysis } from '@drzl/analyzer';
1
+ import { Table as Table$1, Column, Analysis } from '@drzl/analyzer';
2
2
 
3
3
  /**
4
4
  * One place that decides what a generated module is called, on disk and in an import.
@@ -244,6 +244,18 @@ interface LengthCheck {
244
244
  value: string;
245
245
  name?: string;
246
246
  }
247
+ /**
248
+ * A constraint on an array column's element count, from `CHECK (cardinality(tags) > 0)`.
249
+ *
250
+ * The array analogue of `LengthCheck`, and free of the question that one carries: an element
251
+ * count is the same number in SQL and in JavaScript, with no encoding involved.
252
+ */
253
+ interface CardinalityCheck {
254
+ column: string;
255
+ operator: ColumnCheck['operator'];
256
+ value: string;
257
+ name?: string;
258
+ }
247
259
  /** A check that was understood, or the reason it was not. */
248
260
  type ParsedCheck = {
249
261
  ok: true;
@@ -251,6 +263,7 @@ type ParsedCheck = {
251
263
  sets?: ColumnSet[];
252
264
  rows?: RowCheck[];
253
265
  lengths?: LengthCheck[];
266
+ cardinalities?: CardinalityCheck[];
254
267
  } | {
255
268
  ok: false;
256
269
  reason: string;
@@ -271,6 +284,30 @@ declare function parseCheck(expression: string | undefined, name?: string): Pars
271
284
  */
272
285
  declare function describeSet(set: ColumnSet): string;
273
286
 
287
+ /**
288
+ * A duplicate finder for a batch of rows, emitted beside the schemas.
289
+ *
290
+ * A validator checks one row at a time, so uniqueness is the one constraint it structurally
291
+ * cannot see: whether a value is unique is a fact about the table, not about the row. That is
292
+ * fine for a single insert, where the database answers immediately. It is not fine for a bulk
293
+ * insert, where a batch of a thousand rows fails whole on one collision and the error names a
294
+ * constraint rather than a row.
295
+ *
296
+ * What *is* checkable without the database is whether a batch collides with **itself**, and that
297
+ * is worth checking, because it is the half a user can fix before sending anything.
298
+ *
299
+ * The emitted function is plain TypeScript with no reference to any validation library, so all
300
+ * four generators emit the same thing. It is rendered from one place for that reason.
301
+ */
302
+
303
+ /**
304
+ * `findDuplicate<Table>s` for a table with unique constraints, or nothing.
305
+ *
306
+ * `rowType` is the name of the insert type, which is what a caller has in hand before an insert.
307
+ * Passed in rather than derived, because each generator names its types differently.
308
+ */
309
+ declare function renderDuplicateFinder(table: Table$1, fnName: string, rowType: string): string | undefined;
310
+
274
311
  interface Table {
275
312
  name: string;
276
313
  tsName: string;
@@ -448,4 +485,4 @@ declare function updateColumns(table: Table): Column[];
448
485
  declare function selectColumns(table: Table): Column[];
449
486
  declare function formatCode(code: string, filePath: string, fmt?: FormatOptions): Promise<any>;
450
487
 
451
- export { AFFIX_PROBE_TABLE, type AffixIssue, type AffixOptions, type AffixValue, CODEPOINT_LENGTH, COLUMN_FORMATS, type ColumnCheck, type ColumnSet, DEFAULT_IMPORT_EXTENSION, DEFAULT_MODE_PREFIX, DEFAULT_SCHEMA_SUFFIX, DEFAULT_TYPE_SUFFIX, type FormatOptions, IMPORT_EXTENSIONS, type ImportExtension, type LengthCheck, NAME_MODES, type NameMode, type ParsedCheck, type ResolvedAffix, type RowCheck, type Table, type TableCase, type ValidationGenerateOptions, type ValidationLibrary, type ValidationRenderer, applyTableCase, describeSet, formatCode, importSpecifier, insertColumns, isGeneratedColumn, isIntegerColumn, moduleFileName, moduleSpecifier, parseCheck, pascalCase, resolveAffix, resolveConfiguredImport, schemaName, selectColumns, typeName, updateColumns, validateAffix };
488
+ export { AFFIX_PROBE_TABLE, type AffixIssue, type AffixOptions, type AffixValue, CODEPOINT_LENGTH, COLUMN_FORMATS, type CardinalityCheck, type ColumnCheck, type ColumnSet, DEFAULT_IMPORT_EXTENSION, DEFAULT_MODE_PREFIX, DEFAULT_SCHEMA_SUFFIX, DEFAULT_TYPE_SUFFIX, type FormatOptions, IMPORT_EXTENSIONS, type ImportExtension, type LengthCheck, NAME_MODES, type NameMode, type ParsedCheck, type ResolvedAffix, type RowCheck, type Table, type TableCase, type ValidationGenerateOptions, type ValidationLibrary, type ValidationRenderer, applyTableCase, describeSet, formatCode, importSpecifier, insertColumns, isGeneratedColumn, isIntegerColumn, moduleFileName, moduleSpecifier, parseCheck, pascalCase, renderDuplicateFinder, resolveAffix, resolveConfiguredImport, schemaName, selectColumns, typeName, updateColumns, validateAffix };
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import "./chunk-ZZGILH5A.js";
3
3
  // src/checks.ts
4
4
  var COMPARISON = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(>=|<=|<>|!=|>|<|=)\s*(.+?)\s*$/;
5
5
  var IN_LIST = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s+IN\s*\((.+)\)\s*$/i;
6
+ var CARDINALITY_OF = /^\s*(?:cardinality\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)|array_length\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*1\s*\))\s*(>=|<=|<>|!=|>|<|=)\s*(\d+)\s*$/i;
6
7
  var LENGTH_OF = /^\s*(?:length|char_length)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)\s*(>=|<=|<>|!=|>|<|=)\s*(\d+)\s*$/i;
7
8
  function splitTopLevelAnd(expr) {
8
9
  const parts = [];
@@ -125,6 +126,7 @@ function parseCheck(expression, name) {
125
126
  const sets = [];
126
127
  const rows = [];
127
128
  const lengths = [];
129
+ const cardinalities = [];
128
130
  for (const part of parts) {
129
131
  const parsed = parseCheck(part, name);
130
132
  if (!parsed.ok)
@@ -133,13 +135,15 @@ function parseCheck(expression, name) {
133
135
  if (parsed.sets) sets.push(...parsed.sets);
134
136
  if (parsed.rows) rows.push(...parsed.rows);
135
137
  if (parsed.lengths) lengths.push(...parsed.lengths);
138
+ if (parsed.cardinalities) cardinalities.push(...parsed.cardinalities);
136
139
  }
137
140
  return {
138
141
  ok: true,
139
142
  checks,
140
143
  ...sets.length ? { sets } : {},
141
144
  ...rows.length ? { rows } : {},
142
- ...lengths.length ? { lengths } : {}
145
+ ...lengths.length ? { lengths } : {},
146
+ ...cardinalities.length ? { cardinalities } : {}
143
147
  };
144
148
  }
145
149
  const lengthOf = expr.match(LENGTH_OF);
@@ -151,6 +155,22 @@ function parseCheck(expression, name) {
151
155
  lengths: [{ column: lengthOf[1], operator: op2, value: lengthOf[3], name }]
152
156
  };
153
157
  }
158
+ const cardinalityOf = expr.match(CARDINALITY_OF);
159
+ if (cardinalityOf) {
160
+ const op2 = cardinalityOf[3] === "!=" ? "<>" : cardinalityOf[3];
161
+ return {
162
+ ok: true,
163
+ checks: [],
164
+ cardinalities: [
165
+ {
166
+ column: cardinalityOf[1] ?? cardinalityOf[2],
167
+ operator: op2,
168
+ value: cardinalityOf[4],
169
+ ...name ? { name } : {}
170
+ }
171
+ ]
172
+ };
173
+ }
154
174
  const inList = expr.match(IN_LIST);
155
175
  if (inList) {
156
176
  const raw = splitTopLevelCommas(inList[2]);
@@ -359,6 +379,54 @@ function validateAffix(affix, schemaSuffix) {
359
379
  return issues;
360
380
  }
361
381
 
382
+ // src/duplicates.ts
383
+ function usableKeys(table) {
384
+ return (table.unique ?? []).filter((k) => k.columns.length > 0);
385
+ }
386
+ function renderDuplicateFinder(table, fnName, rowType) {
387
+ const keys = usableKeys(table);
388
+ if (!keys.length) return void 0;
389
+ const constraints = keys.map((k, i) => {
390
+ const name = k.name ?? k.columns.join("_");
391
+ return ` { name: ${JSON.stringify(name)}, columns: ${JSON.stringify(k.columns)} }${i === keys.length - 1 ? "" : ","}`;
392
+ }).join("\n");
393
+ return `/**
394
+ * Rows in \`rows\` that collide with an earlier row on a unique constraint.
395
+ *
396
+ * Uniqueness is a fact about the table rather than about a row, so no schema can check it. This
397
+ * checks the half that needs no database: whether the batch collides with itself. A batch that
398
+ * passes here can still collide with rows already stored.
399
+ *
400
+ * A constraint is skipped for any row where one of its columns is null or absent, matching SQL,
401
+ * where NULL is not equal to NULL and a unique index therefore permits repeats.
402
+ */
403
+ export function ${fnName}(
404
+ rows: readonly ${rowType}[]
405
+ ): Array<{ index: number; constraint: string; firstIndex: number }> {
406
+ const constraints = [
407
+ ${constraints}
408
+ ] as const;
409
+ const seen = constraints.map(() => new Map<string, number>());
410
+ const out: Array<{ index: number; constraint: string; firstIndex: number }> = [];
411
+
412
+ for (let i = 0; i < rows.length; i++) {
413
+ const row = rows[i] as Record<string, unknown>;
414
+ for (let c = 0; c < constraints.length; c++) {
415
+ const cols = constraints[c].columns;
416
+ const values = cols.map((col) => row?.[col]);
417
+ if (values.some((v) => v === null || v === undefined)) continue;
418
+ // JSON, so a composite key compares by value and \`[1, "2"]\` never collides with
419
+ // \`["1", 2]\`. A join on a separator would.
420
+ const key = JSON.stringify(values);
421
+ const first = seen[c].get(key);
422
+ if (first === undefined) seen[c].set(key, i);
423
+ else out.push({ index: i, constraint: constraints[c].name, firstIndex: first });
424
+ }
425
+ }
426
+ return out;
427
+ }`;
428
+ }
429
+
362
430
  // src/index.ts
363
431
  function isGeneratedColumn(c, _primaryKeyColumns = []) {
364
432
  return c.isGenerated;
@@ -430,6 +498,7 @@ export {
430
498
  moduleSpecifier,
431
499
  parseCheck,
432
500
  pascalCase,
501
+ renderDuplicateFinder,
433
502
  resolveAffix,
434
503
  resolveConfiguredImport,
435
504
  schemaName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drzl/validation-core",
3
- "version": "3.12.0",
3
+ "version": "3.14.0",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "sideEffects": false,
13
13
  "dependencies": {
14
- "@drzl/analyzer": "^1.11.0"
14
+ "@drzl/analyzer": "^1.14.0"
15
15
  },
16
16
  "devDependencies": {
17
17
  "tsup": "^8.5.1",
@@ -36,6 +36,6 @@
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsup src/index.ts --dts --format esm,cjs",
39
- "test": "vitest run"
39
+ "test": "vitest run --testTimeout=20000"
40
40
  }
41
41
  }