@prisma-next/sql-contract-psl 0.5.0-dev.9 → 0.5.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.
@@ -35,17 +35,21 @@ import {
35
35
  type ForeignKeyNode,
36
36
  type IndexNode,
37
37
  type ModelNode,
38
+ type PrimaryKeyNode,
38
39
  type UniqueConstraintNode,
39
40
  } from '@prisma-next/sql-contract-ts/contract-builder';
40
41
  import { ifDefined } from '@prisma-next/utils/defined';
41
42
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
42
43
  import {
44
+ findDuplicateFieldName,
43
45
  getAttribute,
46
+ getNamedArgument,
44
47
  getPositionalArgument,
45
48
  mapFieldNamesToColumns,
46
49
  parseAttributeFieldList,
47
50
  parseConstraintMapArgument,
48
51
  parseMapName,
52
+ parseObjectLiteralStringMap,
49
53
  parseQuotedStringLiteral,
50
54
  } from './psl-attribute-parsing';
51
55
  import type { ColumnDescriptor } from './psl-column-resolution';
@@ -77,19 +81,19 @@ import {
77
81
 
78
82
  export interface InterpretPslDocumentToSqlContractInput {
79
83
  readonly document: ParsePslDocumentResult;
80
- readonly target: TargetPackRef<'sql', 'postgres'>;
84
+ readonly target: TargetPackRef<'sql', string>;
81
85
  readonly scalarTypeDescriptors: ReadonlyMap<string, ColumnDescriptor>;
82
86
  readonly composedExtensionPacks?: readonly string[];
83
- readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', 'postgres'>[];
87
+ readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[];
84
88
  readonly controlMutationDefaults?: ControlMutationDefaults;
85
89
  readonly authoringContributions?: AuthoringContributions;
86
90
  }
87
91
 
88
92
  function buildComposedExtensionPackRefs(
89
- target: TargetPackRef<'sql', 'postgres'>,
93
+ target: TargetPackRef<'sql', string>,
90
94
  extensionIds: readonly string[],
91
- extensionPackRefs: readonly ExtensionPackRef<'sql', 'postgres'>[] = [],
92
- ): Record<string, ExtensionPackRef<'sql', 'postgres'>> | undefined {
95
+ extensionPackRefs: readonly ExtensionPackRef<'sql', string>[] = [],
96
+ ): Record<string, ExtensionPackRef<'sql', string>> | undefined {
93
97
  if (extensionIds.length === 0) {
94
98
  return undefined;
95
99
  }
@@ -106,7 +110,7 @@ function buildComposedExtensionPackRefs(
106
110
  familyId: target.familyId,
107
111
  targetId: target.targetId,
108
112
  version: '0.0.1',
109
- } satisfies ExtensionPackRef<'sql', 'postgres'>),
113
+ } satisfies ExtensionPackRef<'sql', string>),
110
114
  ]),
111
115
  );
112
116
  }
@@ -219,6 +223,7 @@ function validateNamedTypeAttributes(input: {
219
223
  readonly sourceId: string;
220
224
  readonly diagnostics: ContractSourceDiagnostic[];
221
225
  readonly composedExtensions: ReadonlySet<string>;
226
+ readonly authoringContributions: AuthoringContributions | undefined;
222
227
  readonly allowDbNativeType: boolean;
223
228
  readonly familyId: string;
224
229
  readonly targetId: string;
@@ -250,6 +255,7 @@ function validateNamedTypeAttributes(input: {
250
255
  const uncomposedNamespace = checkUncomposedNamespace(attribute.name, input.composedExtensions, {
251
256
  familyId: input.familyId,
252
257
  targetId: input.targetId,
258
+ authoringContributions: input.authoringContributions,
253
259
  });
254
260
  if (uncomposedNamespace) {
255
261
  reportUncomposedNamespace({
@@ -289,6 +295,7 @@ function resolveNamedTypeDeclarations(input: ResolveNamedTypeDeclarationsInput):
289
295
  sourceId: input.sourceId,
290
296
  diagnostics: input.diagnostics,
291
297
  composedExtensions: input.composedExtensions,
298
+ authoringContributions: input.authoringContributions,
292
299
  allowDbNativeType: false,
293
300
  familyId: input.familyId,
294
301
  targetId: input.targetId,
@@ -367,6 +374,7 @@ function resolveNamedTypeDeclarations(input: ResolveNamedTypeDeclarationsInput):
367
374
  sourceId: input.sourceId,
368
375
  diagnostics: input.diagnostics,
369
376
  composedExtensions: input.composedExtensions,
377
+ authoringContributions: input.authoringContributions,
370
378
  allowDbNativeType: true,
371
379
  familyId: input.familyId,
372
380
  targetId: input.targetId,
@@ -460,18 +468,24 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
460
468
  scalarTypeDescriptors: input.scalarTypeDescriptors,
461
469
  });
462
470
 
463
- const primaryKeyFields = resolvedFields.filter((field) => field.isId);
464
- const primaryKeyColumns = primaryKeyFields.map((field) => field.columnName);
465
- const primaryKeyName = primaryKeyFields.length === 1 ? primaryKeyFields[0]?.idName : undefined;
466
- const isVariantModel = model.attributes.some((attr) => attr.name === 'base');
467
- if (primaryKeyColumns.length === 0 && !isVariantModel) {
471
+ const inlineIdFields = resolvedFields.filter((field) => field.isId);
472
+ if (inlineIdFields.length > 1) {
468
473
  diagnostics.push({
469
- code: 'PSL_MISSING_PRIMARY_KEY',
470
- message: `Model "${model.name}" must declare at least one @id field for SQL provider`,
474
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
475
+ message: `Model "${model.name}" cannot declare inline @id on multiple fields; use model-level @@id([...]) for composite identity`,
471
476
  sourceId,
472
477
  span: model.span,
473
478
  });
474
479
  }
480
+ const singleInlineIdField = inlineIdFields.length === 1 ? inlineIdFields[0] : undefined;
481
+ let primaryKey: PrimaryKeyNode | undefined = singleInlineIdField
482
+ ? {
483
+ columns: [singleInlineIdField.columnName],
484
+ ...ifDefined('name', singleInlineIdField.idName),
485
+ }
486
+ : undefined;
487
+ const hasInlinePrimaryKey = primaryKey !== undefined;
488
+ let blockPrimaryKeyDeclared = false;
475
489
 
476
490
  const resultBackrelationCandidates: ModelBackrelationCandidate[] = [];
477
491
  for (const field of model.fields) {
@@ -483,6 +497,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
483
497
  field,
484
498
  sourceId,
485
499
  composedExtensions: input.composedExtensions,
500
+ authoringContributions: input.authoringContributions,
486
501
  diagnostics,
487
502
  familyId: input.familyId,
488
503
  targetId: input.targetId,
@@ -557,17 +572,107 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
557
572
  if (modelAttribute.name === 'discriminator' || modelAttribute.name === 'base') {
558
573
  continue;
559
574
  }
575
+ const attributeLabel = `Model "${model.name}" @@${modelAttribute.name}`;
576
+ if (modelAttribute.name === 'id') {
577
+ if (blockPrimaryKeyDeclared) {
578
+ diagnostics.push({
579
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
580
+ message: `Model "${model.name}" declares @@id more than once`,
581
+ sourceId,
582
+ span: modelAttribute.span,
583
+ });
584
+ continue;
585
+ }
586
+ if (hasInlinePrimaryKey) {
587
+ diagnostics.push({
588
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
589
+ message: `Model "${model.name}" cannot declare both field-level @id and model-level @@id`,
590
+ sourceId,
591
+ span: modelAttribute.span,
592
+ });
593
+ blockPrimaryKeyDeclared = true;
594
+ continue;
595
+ }
596
+ const fieldNames = parseAttributeFieldList({
597
+ attribute: modelAttribute,
598
+ sourceId,
599
+ diagnostics,
600
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
601
+ entityLabel: attributeLabel,
602
+ });
603
+ if (!fieldNames) {
604
+ continue;
605
+ }
606
+ const duplicateFieldName = findDuplicateFieldName(fieldNames);
607
+ if (duplicateFieldName !== undefined) {
608
+ diagnostics.push({
609
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
610
+ message: `${attributeLabel} list contains duplicate field "${duplicateFieldName}"`,
611
+ sourceId,
612
+ span: modelAttribute.span,
613
+ });
614
+ continue;
615
+ }
616
+ const nullableFieldName = fieldNames.find(
617
+ (name) => model.fields.find((f) => f.name === name)?.optional === true,
618
+ );
619
+ if (nullableFieldName !== undefined) {
620
+ diagnostics.push({
621
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
622
+ message: `${attributeLabel} cannot include optional field "${nullableFieldName}"; primary key columns must be NOT NULL`,
623
+ sourceId,
624
+ span: modelAttribute.span,
625
+ });
626
+ continue;
627
+ }
628
+ const columnNames = mapFieldNamesToColumns({
629
+ modelName: model.name,
630
+ fieldNames,
631
+ mapping,
632
+ sourceId,
633
+ diagnostics,
634
+ span: modelAttribute.span,
635
+ entityLabel: attributeLabel,
636
+ });
637
+ if (!columnNames) {
638
+ continue;
639
+ }
640
+ const constraintName = parseConstraintMapArgument({
641
+ attribute: modelAttribute,
642
+ sourceId,
643
+ diagnostics,
644
+ entityLabel: attributeLabel,
645
+ span: modelAttribute.span,
646
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
647
+ });
648
+ primaryKey = {
649
+ columns: columnNames,
650
+ ...ifDefined('name', constraintName),
651
+ };
652
+ blockPrimaryKeyDeclared = true;
653
+ continue;
654
+ }
560
655
  if (modelAttribute.name === 'unique' || modelAttribute.name === 'index') {
561
656
  const fieldNames = parseAttributeFieldList({
562
657
  attribute: modelAttribute,
563
658
  sourceId,
564
659
  diagnostics,
565
660
  code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
566
- messagePrefix: `Model "${model.name}" @@${modelAttribute.name}`,
661
+ entityLabel: attributeLabel,
567
662
  });
568
663
  if (!fieldNames) {
569
664
  continue;
570
665
  }
666
+ const duplicateFieldName = findDuplicateFieldName(fieldNames);
667
+ if (duplicateFieldName !== undefined) {
668
+ diagnostics.push({
669
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
670
+ message: `${attributeLabel} list contains duplicate field "${duplicateFieldName}"`,
671
+ sourceId,
672
+ span: modelAttribute.span,
673
+ });
674
+ continue;
675
+ }
571
676
  const columnNames = mapFieldNamesToColumns({
572
677
  modelName: model.name,
573
678
  fieldNames,
@@ -575,7 +680,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
575
680
  sourceId,
576
681
  diagnostics,
577
682
  span: modelAttribute.span,
578
- contextLabel: `Model "${model.name}" @@${modelAttribute.name}`,
683
+ entityLabel: attributeLabel,
579
684
  });
580
685
  if (!columnNames) {
581
686
  continue;
@@ -584,7 +689,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
584
689
  attribute: modelAttribute,
585
690
  sourceId,
586
691
  diagnostics,
587
- entityLabel: `Model "${model.name}" @@${modelAttribute.name}`,
692
+ entityLabel: attributeLabel,
588
693
  span: modelAttribute.span,
589
694
  code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
590
695
  });
@@ -594,9 +699,51 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
594
699
  ...ifDefined('name', constraintName),
595
700
  });
596
701
  } else {
702
+ const indexEntityLabel = `Model "${model.name}" @@index`;
703
+ const rawTypeArg = getNamedArgument(modelAttribute, 'type');
704
+ let indexType: string | undefined;
705
+ if (rawTypeArg !== undefined) {
706
+ const parsed = parseQuotedStringLiteral(rawTypeArg);
707
+ if (parsed === undefined) {
708
+ diagnostics.push({
709
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
710
+ message: `${indexEntityLabel} type argument must be a quoted string literal`,
711
+ sourceId,
712
+ span: modelAttribute.span,
713
+ });
714
+ continue;
715
+ }
716
+ indexType = parsed;
717
+ }
718
+ const rawOptionsArg = getNamedArgument(modelAttribute, 'options');
719
+ let indexOptions: Record<string, string> | undefined;
720
+ if (rawOptionsArg !== undefined) {
721
+ if (indexType === undefined) {
722
+ diagnostics.push({
723
+ code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
724
+ message: `${indexEntityLabel} options argument requires a type argument`,
725
+ sourceId,
726
+ span: modelAttribute.span,
727
+ });
728
+ continue;
729
+ }
730
+ const parsed = parseObjectLiteralStringMap({
731
+ raw: rawOptionsArg,
732
+ diagnostics,
733
+ sourceId,
734
+ span: modelAttribute.span,
735
+ entityLabel: indexEntityLabel,
736
+ });
737
+ if (parsed === undefined) {
738
+ continue;
739
+ }
740
+ indexOptions = parsed;
741
+ }
597
742
  indexNodes.push({
598
743
  columns: columnNames,
599
744
  ...ifDefined('name', constraintName),
745
+ ...ifDefined('type', indexType),
746
+ ...ifDefined('options', indexOptions),
600
747
  });
601
748
  }
602
749
  continue;
@@ -604,7 +751,11 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
604
751
  const uncomposedNamespace = checkUncomposedNamespace(
605
752
  modelAttribute.name,
606
753
  input.composedExtensions,
607
- { familyId: input.familyId, targetId: input.targetId },
754
+ {
755
+ familyId: input.familyId,
756
+ targetId: input.targetId,
757
+ authoringContributions: input.authoringContributions,
758
+ },
608
759
  );
609
760
  if (uncomposedNamespace) {
610
761
  reportUncomposedNamespace({
@@ -678,7 +829,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
678
829
  sourceId,
679
830
  diagnostics,
680
831
  span: relationAttribute.relation.span,
681
- contextLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
832
+ entityLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
682
833
  });
683
834
  if (!localColumns) {
684
835
  continue;
@@ -690,7 +841,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
690
841
  sourceId,
691
842
  diagnostics,
692
843
  span: relationAttribute.relation.span,
693
- contextLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
844
+ entityLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
694
845
  });
695
846
  if (!referencedColumns) {
696
847
  continue;
@@ -762,16 +913,9 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
762
913
  descriptor: resolvedField.descriptor,
763
914
  nullable: resolvedField.field.optional,
764
915
  ...ifDefined('default', resolvedField.defaultValue),
765
- ...ifDefined('executionDefault', resolvedField.executionDefault),
916
+ ...ifDefined('executionDefaults', resolvedField.executionDefaults),
766
917
  })),
767
- ...(primaryKeyColumns.length > 0
768
- ? {
769
- id: {
770
- columns: primaryKeyColumns,
771
- ...ifDefined('name', primaryKeyName),
772
- },
773
- }
774
- : {}),
918
+ ...ifDefined('id', primaryKey),
775
919
  ...(uniqueConstraints.length > 0 ? { uniques: uniqueConstraints } : {}),
776
920
  ...(indexNodes.length > 0 ? { indexes: indexNodes } : {}),
777
921
  ...(foreignKeyNodes.length > 0 ? { foreignKeys: foreignKeyNodes } : {}),
package/src/provider.ts CHANGED
@@ -10,8 +10,8 @@ import type { ColumnDescriptor } from './psl-column-resolution';
10
10
 
11
11
  export interface PrismaContractOptions {
12
12
  readonly output?: string;
13
- readonly target: TargetPackRef<'sql', 'postgres'>;
14
- readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', 'postgres'>[];
13
+ readonly target: TargetPackRef<'sql', string>;
14
+ readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[];
15
15
  }
16
16
 
17
17
  function buildColumnDescriptorMap(
@@ -20,9 +20,7 @@ function buildColumnDescriptorMap(
20
20
  ): ReadonlyMap<string, ColumnDescriptor> {
21
21
  const result = new Map<string, ColumnDescriptor>();
22
22
  for (const [typeName, codecId] of scalarTypeDescriptors) {
23
- const codec = codecLookup.get(codecId);
24
- if (!codec) continue;
25
- const nativeType = codec.targetTypes[0];
23
+ const nativeType = codecLookup.targetTypesFor(codecId)?.[0];
26
24
  if (nativeType === undefined) continue;
27
25
  result.set(typeName, { codecId, nativeType });
28
26
  }
@@ -133,6 +133,132 @@ export function getPositionalArguments(attribute: PslAttribute): readonly string
133
133
  .map((arg) => (arg.kind === 'positional' ? arg.value : ''));
134
134
  }
135
135
 
136
+ /**
137
+ * Parses a PSL object-literal attribute argument value of the form
138
+ * `{ key1: "value1", key2: "value2" }` into a `Record<string, string>`.
139
+ *
140
+ * V1 admits string literals only as leaf values. Boolean and number
141
+ * literals are rejected. Trailing commas are allowed.
142
+ *
143
+ * Returns the parsed record, or pushes a diagnostic and returns undefined
144
+ * on malformed input or non-string leaves.
145
+ */
146
+ export function parseObjectLiteralStringMap(input: {
147
+ readonly raw: string;
148
+ readonly diagnostics: ContractSourceDiagnostic[];
149
+ readonly sourceId: string;
150
+ readonly span: PslSpan;
151
+ readonly entityLabel: string;
152
+ }): Record<string, string> | undefined {
153
+ const trimmed = input.raw.trim();
154
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
155
+ return pushInvalidAttributeArgument({
156
+ diagnostics: input.diagnostics,
157
+ sourceId: input.sourceId,
158
+ span: input.span,
159
+ message: `${input.entityLabel} expected an object literal value of the form { key: "value", ... }`,
160
+ });
161
+ }
162
+ const body = trimmed.slice(1, -1).trim();
163
+ if (body.length === 0) {
164
+ return {};
165
+ }
166
+ const result: Record<string, string> = {};
167
+ for (const part of splitObjectLiteralEntries(body)) {
168
+ const colonAt = findTopLevelColon(part);
169
+ if (colonAt === -1) {
170
+ return pushInvalidAttributeArgument({
171
+ diagnostics: input.diagnostics,
172
+ sourceId: input.sourceId,
173
+ span: input.span,
174
+ message: `${input.entityLabel} object-literal entry "${part}" is missing a "key: value" colon`,
175
+ });
176
+ }
177
+ const key = part.slice(0, colonAt).trim();
178
+ const rawValue = part.slice(colonAt + 1).trim();
179
+ if (key.length === 0 || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
180
+ return pushInvalidAttributeArgument({
181
+ diagnostics: input.diagnostics,
182
+ sourceId: input.sourceId,
183
+ span: input.span,
184
+ message: `${input.entityLabel} object-literal key "${key}" must be a bare identifier`,
185
+ });
186
+ }
187
+ const parsedString = parseQuotedStringLiteral(rawValue);
188
+ if (parsedString === undefined) {
189
+ return pushInvalidAttributeArgument({
190
+ diagnostics: input.diagnostics,
191
+ sourceId: input.sourceId,
192
+ span: input.span,
193
+ message: `${input.entityLabel} object-literal value for "${key}" must be a quoted string literal (V1 PSL @@index options support string leaves only; use the TS authoring surface for non-string options)`,
194
+ });
195
+ }
196
+ if (Object.hasOwn(result, key)) {
197
+ return pushInvalidAttributeArgument({
198
+ diagnostics: input.diagnostics,
199
+ sourceId: input.sourceId,
200
+ span: input.span,
201
+ message: `${input.entityLabel} object-literal key "${key}" appears more than once`,
202
+ });
203
+ }
204
+ result[key] = parsedString;
205
+ }
206
+ return result;
207
+ }
208
+
209
+ function splitObjectLiteralEntries(body: string): readonly string[] {
210
+ const parts: string[] = [];
211
+ let depthBrace = 0;
212
+ let depthBracket = 0;
213
+ let depthParen = 0;
214
+ let quote: '"' | "'" | null = null;
215
+ let start = 0;
216
+ for (let index = 0; index < body.length; index += 1) {
217
+ const ch = body[index] ?? '';
218
+ if (quote) {
219
+ if (ch === quote && body[index - 1] !== '\\') {
220
+ quote = null;
221
+ }
222
+ continue;
223
+ }
224
+ if (ch === '"' || ch === "'") {
225
+ quote = ch;
226
+ continue;
227
+ }
228
+ if (ch === '{') depthBrace += 1;
229
+ else if (ch === '}') depthBrace = Math.max(0, depthBrace - 1);
230
+ else if (ch === '[') depthBracket += 1;
231
+ else if (ch === ']') depthBracket = Math.max(0, depthBracket - 1);
232
+ else if (ch === '(') depthParen += 1;
233
+ else if (ch === ')') depthParen = Math.max(0, depthParen - 1);
234
+ else if (ch === ',' && depthBrace === 0 && depthBracket === 0 && depthParen === 0) {
235
+ const segment = body.slice(start, index).trim();
236
+ if (segment.length > 0) parts.push(segment);
237
+ start = index + 1;
238
+ }
239
+ }
240
+ const tail = body.slice(start).trim();
241
+ if (tail.length > 0) parts.push(tail);
242
+ return parts;
243
+ }
244
+
245
+ function findTopLevelColon(entry: string): number {
246
+ let quote: '"' | "'" | null = null;
247
+ for (let index = 0; index < entry.length; index += 1) {
248
+ const ch = entry[index] ?? '';
249
+ if (quote) {
250
+ if (ch === quote && entry[index - 1] !== '\\') quote = null;
251
+ continue;
252
+ }
253
+ if (ch === '"' || ch === "'") {
254
+ quote = ch;
255
+ continue;
256
+ }
257
+ if (ch === ':') return index;
258
+ }
259
+ return -1;
260
+ }
261
+
136
262
  export function pushInvalidAttributeArgument(input: {
137
263
  readonly diagnostics: ContractSourceDiagnostic[];
138
264
  readonly sourceId: string;
@@ -251,13 +377,13 @@ export function parseAttributeFieldList(input: {
251
377
  readonly sourceId: string;
252
378
  readonly diagnostics: ContractSourceDiagnostic[];
253
379
  readonly code: string;
254
- readonly messagePrefix: string;
380
+ readonly entityLabel: string;
255
381
  }): readonly string[] | undefined {
256
382
  const raw = getNamedArgument(input.attribute, 'fields') ?? getPositionalArgument(input.attribute);
257
383
  if (!raw) {
258
384
  input.diagnostics.push({
259
385
  code: input.code,
260
- message: `${input.messagePrefix} requires fields list argument`,
386
+ message: `${input.entityLabel} requires fields list argument`,
261
387
  sourceId: input.sourceId,
262
388
  span: input.attribute.span,
263
389
  });
@@ -267,7 +393,7 @@ export function parseAttributeFieldList(input: {
267
393
  if (!fields || fields.length === 0) {
268
394
  input.diagnostics.push({
269
395
  code: input.code,
270
- message: `${input.messagePrefix} requires bracketed field list argument`,
396
+ message: `${input.entityLabel} requires bracketed field list argument`,
271
397
  sourceId: input.sourceId,
272
398
  span: input.attribute.span,
273
399
  });
@@ -276,6 +402,15 @@ export function parseAttributeFieldList(input: {
276
402
  return fields;
277
403
  }
278
404
 
405
+ export function findDuplicateFieldName(fieldNames: readonly string[]): string | undefined {
406
+ const seen = new Set<string>();
407
+ for (const name of fieldNames) {
408
+ if (seen.has(name)) return name;
409
+ seen.add(name);
410
+ }
411
+ return undefined;
412
+ }
413
+
279
414
  export function mapFieldNamesToColumns(input: {
280
415
  readonly modelName: string;
281
416
  readonly fieldNames: readonly string[];
@@ -283,7 +418,7 @@ export function mapFieldNamesToColumns(input: {
283
418
  readonly sourceId: string;
284
419
  readonly diagnostics: ContractSourceDiagnostic[];
285
420
  readonly span: PslSpan;
286
- readonly contextLabel: string;
421
+ readonly entityLabel: string;
287
422
  }): readonly string[] | undefined {
288
423
  const columns: string[] = [];
289
424
  for (const fieldName of input.fieldNames) {
@@ -291,7 +426,7 @@ export function mapFieldNamesToColumns(input: {
291
426
  if (!columnName) {
292
427
  input.diagnostics.push({
293
428
  code: 'PSL_INVALID_ATTRIBUTE_ARGUMENT',
294
- message: `${input.contextLabel} references unknown field "${input.modelName}.${fieldName}"`,
429
+ message: `${input.entityLabel} references unknown field "${input.modelName}.${fieldName}"`,
295
430
  sourceId: input.sourceId,
296
431
  span: input.span,
297
432
  });
@@ -311,6 +311,12 @@ function parsePslAuthoringArgumentValue(
311
311
  switch (descriptor.kind) {
312
312
  case 'string':
313
313
  return unquoteStringLiteral(rawValue);
314
+ case 'boolean': {
315
+ const trimmed = rawValue.trim();
316
+ if (trimmed === 'true') return true;
317
+ if (trimmed === 'false') return false;
318
+ return INVALID_AUTHORING_ARGUMENT;
319
+ }
314
320
  case 'number': {
315
321
  const parsed = Number(unquoteStringLiteral(rawValue));
316
322
  return Number.isNaN(parsed) ? INVALID_AUTHORING_ARGUMENT : parsed;