@prisma-next/sql-contract-psl 0.5.0-dev.9 → 0.6.0-dev.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/README.md +9 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -3
- package/dist/{interpreter-iFCRN9nb.mjs → interpreter-ijCjxhaU.mjs} +507 -80
- package/dist/interpreter-ijCjxhaU.mjs.map +1 -0
- package/dist/provider.d.mts +2 -2
- package/dist/provider.d.mts.map +1 -1
- package/dist/provider.mjs +3 -6
- package/dist/provider.mjs.map +1 -1
- package/package.json +14 -12
- package/src/interpreter.ts +172 -28
- package/src/provider.ts +3 -5
- package/src/psl-attribute-parsing.ts +140 -5
- package/src/psl-authoring-arguments.ts +6 -0
- package/src/psl-column-resolution.ts +228 -37
- package/src/psl-field-resolution.ts +138 -17
- package/src/psl-relation-resolution.ts +3 -0
- package/dist/interpreter-iFCRN9nb.mjs.map +0 -1
package/src/interpreter.ts
CHANGED
|
@@ -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',
|
|
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',
|
|
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',
|
|
93
|
+
target: TargetPackRef<'sql', string>,
|
|
90
94
|
extensionIds: readonly string[],
|
|
91
|
-
extensionPackRefs: readonly ExtensionPackRef<'sql',
|
|
92
|
-
): Record<string, ExtensionPackRef<'sql',
|
|
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',
|
|
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
|
|
464
|
-
|
|
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: '
|
|
470
|
-
message: `Model "${model.name}"
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
916
|
+
...ifDefined('executionDefaults', resolvedField.executionDefaults),
|
|
766
917
|
})),
|
|
767
|
-
...(
|
|
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',
|
|
14
|
-
readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql',
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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;
|