@prisma-next/sql-contract-psl 0.12.0-dev.7 → 0.12.0-dev.71
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 +10 -2
- package/dist/index.d.mts +11 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{interpreter-QE6eZZof.mjs → interpreter-B5_yovSP.mjs} +393 -23
- package/dist/interpreter-B5_yovSP.mjs.map +1 -0
- package/dist/provider.d.mts +5 -0
- package/dist/provider.d.mts.map +1 -1
- package/dist/provider.mjs +6 -3
- package/dist/provider.mjs.map +1 -1
- package/package.json +14 -14
- package/src/interpreter.ts +478 -20
- package/src/provider.ts +11 -1
- package/src/psl-attribute-parsing.ts +66 -0
- package/src/psl-column-resolution.ts +10 -3
- package/src/psl-field-resolution.ts +6 -0
- package/src/psl-relation-resolution.ts +3 -4
- package/dist/interpreter-QE6eZZof.mjs.map +0 -1
package/src/interpreter.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ContractField,
|
|
9
9
|
ContractModel,
|
|
10
10
|
ContractValueObject,
|
|
11
|
+
ControlPolicy,
|
|
11
12
|
} from '@prisma-next/contract/types';
|
|
12
13
|
import { crossRef } from '@prisma-next/contract/types';
|
|
13
14
|
import type {
|
|
@@ -23,7 +24,6 @@ import type {
|
|
|
23
24
|
MutationDefaultGeneratorDescriptor,
|
|
24
25
|
} from '@prisma-next/framework-components/control';
|
|
25
26
|
import type { Namespace } from '@prisma-next/framework-components/ir';
|
|
26
|
-
import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
|
|
27
27
|
import type {
|
|
28
28
|
ParsePslDocumentResult,
|
|
29
29
|
PslAttribute,
|
|
@@ -37,17 +37,21 @@ import type {
|
|
|
37
37
|
import {
|
|
38
38
|
isPostgresEnumStorageEntry,
|
|
39
39
|
type PostgresEnumStorageEntry,
|
|
40
|
+
type SqlModelStorage,
|
|
40
41
|
type SqlNamespaceTablesInput,
|
|
41
42
|
type StorageTypeInstance,
|
|
42
43
|
} from '@prisma-next/sql-contract/types';
|
|
43
44
|
import {
|
|
44
45
|
buildSqlContractFromDefinition,
|
|
46
|
+
type FieldNode,
|
|
45
47
|
type ForeignKeyNode,
|
|
46
48
|
type IndexNode,
|
|
47
49
|
type ModelNode,
|
|
48
50
|
type PrimaryKeyNode,
|
|
51
|
+
type RelationNode,
|
|
49
52
|
type UniqueConstraintNode,
|
|
50
53
|
} from '@prisma-next/sql-contract-ts/contract-builder';
|
|
54
|
+
import { blindCast } from '@prisma-next/utils/casts';
|
|
51
55
|
import { ifDefined } from '@prisma-next/utils/defined';
|
|
52
56
|
import { notOk, ok, type Result } from '@prisma-next/utils/result';
|
|
53
57
|
import {
|
|
@@ -58,6 +62,7 @@ import {
|
|
|
58
62
|
mapFieldNamesToColumns,
|
|
59
63
|
parseAttributeFieldList,
|
|
60
64
|
parseConstraintMapArgument,
|
|
65
|
+
parseControlPolicyAttribute,
|
|
61
66
|
parseMapName,
|
|
62
67
|
parseObjectLiteralStringMap,
|
|
63
68
|
parseQuotedStringLiteral,
|
|
@@ -97,6 +102,16 @@ export interface InterpretPslDocumentToSqlContractInput {
|
|
|
97
102
|
readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[];
|
|
98
103
|
readonly controlMutationDefaults?: ControlMutationDefaults;
|
|
99
104
|
readonly authoringContributions?: AuthoringContributions;
|
|
105
|
+
/**
|
|
106
|
+
* Extension contracts keyed by space ID. Required for cross-space FK
|
|
107
|
+
* resolution. A composed space must have an entry here; if the space ID
|
|
108
|
+
* appears in `composedExtensionPacks` but is absent from this map, the
|
|
109
|
+
* interpreter emits `PSL_UNKNOWN_CONTRACT_SPACE` and fails fast — there
|
|
110
|
+
* is no silent fallback. If a space's contract is present but the
|
|
111
|
+
* referenced model or namespace is not found in it, the interpreter
|
|
112
|
+
* emits `PSL_UNKNOWN_CROSS_SPACE_TARGET`.
|
|
113
|
+
*/
|
|
114
|
+
readonly composedExtensionContracts: ReadonlyMap<string, Contract>;
|
|
100
115
|
/**
|
|
101
116
|
* Target-supplied `Namespace` factory threaded into
|
|
102
117
|
* `buildSqlContractFromDefinition` for the contract's
|
|
@@ -229,10 +244,6 @@ const UNSPECIFIED_PSL_NAMESPACE_NAME = '__unspecified__';
|
|
|
229
244
|
* slot empty (which means the late-bound default at the `StorageTable`
|
|
230
245
|
* layer; emitted JSON omits the field).
|
|
231
246
|
*/
|
|
232
|
-
function defaultSqlNamespaceIdForTarget(targetId: string): string {
|
|
233
|
-
return targetId === 'postgres' ? 'public' : UNBOUND_NAMESPACE_ID;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
247
|
function resolveNamespaceIdForSqlTarget(input: {
|
|
237
248
|
readonly bucketName: string;
|
|
238
249
|
readonly targetId: string;
|
|
@@ -588,6 +599,8 @@ interface BuildModelNodeInput {
|
|
|
588
599
|
readonly enumTypeDescriptors: Map<string, ColumnDescriptor>;
|
|
589
600
|
readonly namedTypeDescriptors: Map<string, ColumnDescriptor>;
|
|
590
601
|
readonly composedExtensions: Set<string>;
|
|
602
|
+
/** Extension contracts keyed by space ID for cross-space FK table-name resolution. */
|
|
603
|
+
readonly composedExtensionContracts: ReadonlyMap<string, Contract>;
|
|
591
604
|
readonly familyId: string;
|
|
592
605
|
readonly targetId: string;
|
|
593
606
|
readonly authoringContributions: AuthoringContributions | undefined;
|
|
@@ -605,6 +618,8 @@ interface BuildModelNodeResult {
|
|
|
605
618
|
readonly fkRelationMetadata: FkRelationMetadata[];
|
|
606
619
|
readonly backrelationCandidates: ModelBackrelationCandidate[];
|
|
607
620
|
readonly resolvedFields: readonly ResolvedField[];
|
|
621
|
+
/** Cross-contract-space relation nodes that bypass the local back-relation matching. */
|
|
622
|
+
readonly crossSpaceRelations: RelationNode[];
|
|
608
623
|
}
|
|
609
624
|
|
|
610
625
|
function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult {
|
|
@@ -647,6 +662,8 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
647
662
|
: undefined;
|
|
648
663
|
const hasInlinePrimaryKey = primaryKey !== undefined;
|
|
649
664
|
let blockPrimaryKeyDeclared = false;
|
|
665
|
+
let controlPolicyDeclared = false;
|
|
666
|
+
let controlPolicy: ControlPolicy | undefined;
|
|
650
667
|
|
|
651
668
|
const resultBackrelationCandidates: ModelBackrelationCandidate[] = [];
|
|
652
669
|
for (const field of model.fields) {
|
|
@@ -733,6 +750,27 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
733
750
|
if (modelAttribute.name === 'discriminator' || modelAttribute.name === 'base') {
|
|
734
751
|
continue;
|
|
735
752
|
}
|
|
753
|
+
if (modelAttribute.name === 'control') {
|
|
754
|
+
if (controlPolicyDeclared) {
|
|
755
|
+
diagnostics.push({
|
|
756
|
+
code: 'PSL_DUPLICATE_ATTRIBUTE',
|
|
757
|
+
message: `\`@@control\` declared more than once on model "${model.name}".`,
|
|
758
|
+
sourceId,
|
|
759
|
+
span: modelAttribute.span,
|
|
760
|
+
});
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
controlPolicyDeclared = true;
|
|
764
|
+
const parsed = parseControlPolicyAttribute({
|
|
765
|
+
attribute: modelAttribute,
|
|
766
|
+
sourceId,
|
|
767
|
+
diagnostics,
|
|
768
|
+
});
|
|
769
|
+
if (parsed !== undefined) {
|
|
770
|
+
controlPolicy = parsed;
|
|
771
|
+
}
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
736
774
|
const attributeLabel = `Model "${model.name}" @@${modelAttribute.name}`;
|
|
737
775
|
if (modelAttribute.name === 'id') {
|
|
738
776
|
if (blockPrimaryKeyDeclared) {
|
|
@@ -937,13 +975,183 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
937
975
|
}
|
|
938
976
|
|
|
939
977
|
const resultFkRelationMetadata: FkRelationMetadata[] = [];
|
|
978
|
+
const resultCrossSpaceRelations: RelationNode[] = [];
|
|
940
979
|
for (const relationAttribute of relationAttributes) {
|
|
980
|
+
const {
|
|
981
|
+
typeName: fieldTypeName,
|
|
982
|
+
typeNamespaceId: fieldTypeNamespaceId,
|
|
983
|
+
typeContractSpaceId: fieldTypeContractSpaceId,
|
|
984
|
+
} = relationAttribute.field;
|
|
985
|
+
|
|
941
986
|
if (relationAttribute.field.list) {
|
|
987
|
+
// F-list: cross-space list relations are explicitly unsupported (Option B does not
|
|
988
|
+
// navigate, so a list target makes no sense to carry). Emit a diagnostic instead of
|
|
989
|
+
// silently dropping the field — the author needs to know the field was ignored.
|
|
990
|
+
if (fieldTypeContractSpaceId !== undefined) {
|
|
991
|
+
diagnostics.push({
|
|
992
|
+
code: 'PSL_UNSUPPORTED_CROSS_SPACE_LIST',
|
|
993
|
+
message: `Relation field "${model.name}.${relationAttribute.field.name}" is a cross-space list relation (type "${fieldTypeContractSpaceId}:${fieldTypeNamespaceId !== undefined ? `${fieldTypeNamespaceId}.` : ''}${fieldTypeName}[]"). Cross-space relations must be singular in v0.1 — list cross-space relations are not supported.`,
|
|
994
|
+
sourceId,
|
|
995
|
+
span: relationAttribute.field.span,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Cross-contract-space relation: the target model lives in a different contract space
|
|
1002
|
+
// identified by `typeContractSpaceId` (e.g. `supabase:auth.User`).
|
|
1003
|
+
if (fieldTypeContractSpaceId !== undefined) {
|
|
1004
|
+
// Fail fast if the space has no entry in composedExtensionContracts (AC5 PSL half).
|
|
1005
|
+
const extContractForSpace = input.composedExtensionContracts.get(fieldTypeContractSpaceId);
|
|
1006
|
+
if (extContractForSpace === undefined) {
|
|
1007
|
+
diagnostics.push({
|
|
1008
|
+
code: 'PSL_UNKNOWN_CONTRACT_SPACE',
|
|
1009
|
+
message: `Relation field "${model.name}.${relationAttribute.field.name}" references contract space "${fieldTypeContractSpaceId}" which is not declared in extensionPacks. Add "${fieldTypeContractSpaceId}" to extensionPacks in prisma-next.config.ts.`,
|
|
1010
|
+
sourceId,
|
|
1011
|
+
span: relationAttribute.field.span,
|
|
1012
|
+
data: { space: fieldTypeContractSpaceId, suggestedPack: fieldTypeContractSpaceId },
|
|
1013
|
+
});
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const parsedRelation = parseRelationAttribute({
|
|
1018
|
+
attribute: relationAttribute.relation,
|
|
1019
|
+
modelName: model.name,
|
|
1020
|
+
fieldName: relationAttribute.field.name,
|
|
1021
|
+
sourceId,
|
|
1022
|
+
diagnostics,
|
|
1023
|
+
});
|
|
1024
|
+
if (!parsedRelation) {
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
if (!parsedRelation.fields || !parsedRelation.references) {
|
|
1028
|
+
diagnostics.push({
|
|
1029
|
+
code: 'PSL_INVALID_RELATION_ATTRIBUTE',
|
|
1030
|
+
message: `Relation field "${model.name}.${relationAttribute.field.name}" requires fields and references arguments`,
|
|
1031
|
+
sourceId,
|
|
1032
|
+
span: relationAttribute.relation.span,
|
|
1033
|
+
});
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const localColumns = mapFieldNamesToColumns({
|
|
1038
|
+
modelName: model.name,
|
|
1039
|
+
fieldNames: parsedRelation.fields,
|
|
1040
|
+
mapping,
|
|
1041
|
+
sourceId,
|
|
1042
|
+
diagnostics,
|
|
1043
|
+
span: relationAttribute.relation.span,
|
|
1044
|
+
entityLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
|
|
1045
|
+
});
|
|
1046
|
+
if (!localColumns) {
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// For cross-space references the `references` list provides field names from the remote
|
|
1051
|
+
// model. Since the interpreter has no access to the extension contract, these field names
|
|
1052
|
+
// are treated as column names directly (matching the TS builder's cross-space path).
|
|
1053
|
+
const referencedColumns = parsedRelation.references;
|
|
1054
|
+
|
|
1055
|
+
if (localColumns.length !== referencedColumns.length) {
|
|
1056
|
+
diagnostics.push({
|
|
1057
|
+
code: 'PSL_INVALID_RELATION_ATTRIBUTE',
|
|
1058
|
+
message: `Relation field "${model.name}.${relationAttribute.field.name}" must provide the same number of fields and references`,
|
|
1059
|
+
sourceId,
|
|
1060
|
+
span: relationAttribute.relation.span,
|
|
1061
|
+
});
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const onDelete = parsedRelation.onDelete
|
|
1066
|
+
? normalizeReferentialAction({
|
|
1067
|
+
modelName: model.name,
|
|
1068
|
+
fieldName: relationAttribute.field.name,
|
|
1069
|
+
actionName: 'onDelete',
|
|
1070
|
+
actionToken: parsedRelation.onDelete,
|
|
1071
|
+
sourceId,
|
|
1072
|
+
span: relationAttribute.field.span,
|
|
1073
|
+
diagnostics,
|
|
1074
|
+
})
|
|
1075
|
+
: undefined;
|
|
1076
|
+
const onUpdate = parsedRelation.onUpdate
|
|
1077
|
+
? normalizeReferentialAction({
|
|
1078
|
+
modelName: model.name,
|
|
1079
|
+
fieldName: relationAttribute.field.name,
|
|
1080
|
+
actionName: 'onUpdate',
|
|
1081
|
+
actionToken: parsedRelation.onUpdate,
|
|
1082
|
+
sourceId,
|
|
1083
|
+
span: relationAttribute.field.span,
|
|
1084
|
+
diagnostics,
|
|
1085
|
+
})
|
|
1086
|
+
: undefined;
|
|
1087
|
+
|
|
1088
|
+
// Target namespace: use the colon-prefix namespace qualifier, or `__unbound__` when the
|
|
1089
|
+
// no-namespace form is used (e.g. `supabase:User` → AC3).
|
|
1090
|
+
const crossTargetNamespaceId = fieldTypeNamespaceId ?? '__unbound__';
|
|
1091
|
+
|
|
1092
|
+
// Target table name: resolved from the extension contract. The get() check above
|
|
1093
|
+
// guarantees extContractForSpace is defined here; if the model or namespace is not
|
|
1094
|
+
// found in it, emit PSL_UNKNOWN_CROSS_SPACE_TARGET (user typo).
|
|
1095
|
+
const extContract = extContractForSpace;
|
|
1096
|
+
const resolvedTable =
|
|
1097
|
+
extContract.domain.namespaces[crossTargetNamespaceId]?.models[fieldTypeName]?.storage[
|
|
1098
|
+
'table'
|
|
1099
|
+
];
|
|
1100
|
+
if (typeof resolvedTable !== 'string') {
|
|
1101
|
+
const availableModels =
|
|
1102
|
+
Object.keys(extContract.domain.namespaces[crossTargetNamespaceId]?.models ?? {}).join(
|
|
1103
|
+
', ',
|
|
1104
|
+
) || '(none)';
|
|
1105
|
+
diagnostics.push({
|
|
1106
|
+
code: 'PSL_UNKNOWN_CROSS_SPACE_TARGET',
|
|
1107
|
+
message: `Relation field "${model.name}.${relationAttribute.field.name}" references model "${fieldTypeName}" in namespace "${crossTargetNamespaceId}" of space "${fieldTypeContractSpaceId}", but that model was not found in the extension contract. Available models: ${availableModels}`,
|
|
1108
|
+
sourceId,
|
|
1109
|
+
span: relationAttribute.field.span,
|
|
1110
|
+
data: {
|
|
1111
|
+
space: fieldTypeContractSpaceId,
|
|
1112
|
+
namespace: crossTargetNamespaceId,
|
|
1113
|
+
model: fieldTypeName,
|
|
1114
|
+
},
|
|
1115
|
+
});
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
const crossTargetTableName = resolvedTable;
|
|
1119
|
+
|
|
1120
|
+
foreignKeyNodes.push({
|
|
1121
|
+
columns: localColumns,
|
|
1122
|
+
references: {
|
|
1123
|
+
model: fieldTypeName,
|
|
1124
|
+
table: crossTargetTableName,
|
|
1125
|
+
columns: referencedColumns,
|
|
1126
|
+
namespaceId: crossTargetNamespaceId,
|
|
1127
|
+
spaceId: fieldTypeContractSpaceId,
|
|
1128
|
+
},
|
|
1129
|
+
...ifDefined('name', parsedRelation.constraintName),
|
|
1130
|
+
...ifDefined('onDelete', onDelete),
|
|
1131
|
+
...ifDefined('onUpdate', onUpdate),
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
// Build the cross-space RelationNode directly (no local back-relation candidate).
|
|
1135
|
+
// `buildSqlContractFromDefinition` recognises `spaceId` on a RelationNode and routes it
|
|
1136
|
+
// through the cross-space domain-relation path (produces a non-navigable CrossReference).
|
|
1137
|
+
resultCrossSpaceRelations.push({
|
|
1138
|
+
fieldName: relationAttribute.field.name,
|
|
1139
|
+
toModel: fieldTypeName,
|
|
1140
|
+
toTable: crossTargetTableName,
|
|
1141
|
+
cardinality: 'N:1',
|
|
1142
|
+
spaceId: fieldTypeContractSpaceId,
|
|
1143
|
+
namespaceId: crossTargetNamespaceId,
|
|
1144
|
+
on: {
|
|
1145
|
+
parentTable: tableName,
|
|
1146
|
+
parentColumns: localColumns,
|
|
1147
|
+
childTable: crossTargetTableName,
|
|
1148
|
+
childColumns: referencedColumns,
|
|
1149
|
+
},
|
|
1150
|
+
});
|
|
1151
|
+
|
|
942
1152
|
continue;
|
|
943
1153
|
}
|
|
944
1154
|
|
|
945
|
-
const { typeName: fieldTypeName, typeNamespaceId: fieldTypeNamespaceId } =
|
|
946
|
-
relationAttribute.field;
|
|
947
1155
|
const qualifiedTypeName = fieldTypeNamespaceId
|
|
948
1156
|
? `${fieldTypeNamespaceId}.${fieldTypeName}`
|
|
949
1157
|
: fieldTypeName;
|
|
@@ -1103,8 +1311,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
1103
1311
|
...(uniqueConstraints.length > 0 ? { uniques: uniqueConstraints } : {}),
|
|
1104
1312
|
...(indexNodes.length > 0 ? { indexes: indexNodes } : {}),
|
|
1105
1313
|
...(foreignKeyNodes.length > 0 ? { foreignKeys: foreignKeyNodes } : {}),
|
|
1314
|
+
...ifDefined('control', controlPolicy),
|
|
1106
1315
|
},
|
|
1107
1316
|
fkRelationMetadata: resultFkRelationMetadata,
|
|
1317
|
+
crossSpaceRelations: resultCrossSpaceRelations,
|
|
1108
1318
|
backrelationCandidates: resultBackrelationCandidates,
|
|
1109
1319
|
resolvedFields,
|
|
1110
1320
|
};
|
|
@@ -1310,12 +1520,27 @@ function resolvePolymorphism(
|
|
|
1310
1520
|
modelNames: Set<string>,
|
|
1311
1521
|
modelMappings: ReadonlyMap<string, ModelNameMapping>,
|
|
1312
1522
|
modelNamespaceIds: ReadonlyMap<string, string>,
|
|
1313
|
-
|
|
1523
|
+
defaultNamespaceId: string,
|
|
1524
|
+
syntheticPkFieldsByVariant: ReadonlyMap<string, readonly string[]>,
|
|
1525
|
+
stiBaseFieldsByBase: ReadonlyMap<string, readonly string[]>,
|
|
1314
1526
|
sourceId: string,
|
|
1315
1527
|
diagnostics: ContractSourceDiagnostic[],
|
|
1316
1528
|
): Record<string, ContractModel> {
|
|
1317
1529
|
let patched = models;
|
|
1318
1530
|
|
|
1531
|
+
// STI variant columns were materialised onto the base storage table so the
|
|
1532
|
+
// variants' `storage.fields` resolve. They are storage-only on the base — the
|
|
1533
|
+
// domain field belongs to the variant — so strip them from the base model's
|
|
1534
|
+
// domain + storage field maps (the table column, built upstream, stays).
|
|
1535
|
+
for (const [baseName, fieldNames] of stiBaseFieldsByBase) {
|
|
1536
|
+
const baseModel = patched[baseName];
|
|
1537
|
+
if (!baseModel || fieldNames.length === 0) continue;
|
|
1538
|
+
patched = {
|
|
1539
|
+
...patched,
|
|
1540
|
+
[baseName]: stripStorageOnlyDomainFields(baseModel, fieldNames),
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1319
1544
|
for (const [modelName, decl] of discriminatorDeclarations) {
|
|
1320
1545
|
if (baseDeclarations.has(modelName)) {
|
|
1321
1546
|
diagnostics.push({
|
|
@@ -1410,22 +1635,211 @@ function resolvePolymorphism(
|
|
|
1410
1635
|
variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
|
|
1411
1636
|
const resolvedTable = hasExplicitMap ? variantMapping?.tableName : baseMapping?.tableName;
|
|
1412
1637
|
|
|
1638
|
+
const patchedVariant: ContractModel = {
|
|
1639
|
+
...variantModel,
|
|
1640
|
+
base: crossRef(
|
|
1641
|
+
baseDecl.baseName,
|
|
1642
|
+
modelNamespaceIds.get(baseDecl.baseName) ?? defaultNamespaceId,
|
|
1643
|
+
),
|
|
1644
|
+
...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}),
|
|
1645
|
+
};
|
|
1646
|
+
|
|
1413
1647
|
patched = {
|
|
1414
1648
|
...patched,
|
|
1415
|
-
[variantName]:
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
modelNamespaceIds.get(baseDecl.baseName) ?? defaultSqlNamespaceIdForTarget(targetId),
|
|
1420
|
-
),
|
|
1421
|
-
...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}),
|
|
1422
|
-
},
|
|
1649
|
+
[variantName]: stripStorageOnlyDomainFields(
|
|
1650
|
+
patchedVariant,
|
|
1651
|
+
syntheticPkFieldsByVariant.get(variantName) ?? [],
|
|
1652
|
+
),
|
|
1423
1653
|
};
|
|
1424
1654
|
}
|
|
1425
1655
|
|
|
1426
1656
|
return patched;
|
|
1427
1657
|
}
|
|
1428
1658
|
|
|
1659
|
+
/**
|
|
1660
|
+
* Multi-table-inheritance variants (`@@base` + their own `@@map`) live in a
|
|
1661
|
+
* separate table from their base. The ORM joins that table to the base on the
|
|
1662
|
+
* shared primary key (`base.id = variant.id`), so the variant storage table
|
|
1663
|
+
* must carry the base PK column even though the variant domain model declares
|
|
1664
|
+
* only its own fields. This enriches each MTI variant's `ModelNode` with that
|
|
1665
|
+
* link column, a primary key on it, and a FK back to the base table.
|
|
1666
|
+
*
|
|
1667
|
+
* The link column is reported back per variant in `syntheticPkFieldsByVariant`
|
|
1668
|
+
* so the domain-model patch can drop it again — keeping the variant's domain
|
|
1669
|
+
* surface thin (its create/read inputs don't gain a redundant `id`) while the
|
|
1670
|
+
* storage table stays joinable. Single-table-inheritance variants (no own
|
|
1671
|
+
* table) are left untouched.
|
|
1672
|
+
*/
|
|
1673
|
+
function materializeMtiVariantStorageLinks(
|
|
1674
|
+
modelNodes: readonly ModelNode[],
|
|
1675
|
+
baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
|
|
1676
|
+
stiVariantNames: ReadonlySet<string>,
|
|
1677
|
+
): { modelNodes: ModelNode[]; syntheticPkFieldsByVariant: Map<string, readonly string[]> } {
|
|
1678
|
+
const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
|
|
1679
|
+
const syntheticPkFieldsByVariant = new Map<string, readonly string[]>();
|
|
1680
|
+
|
|
1681
|
+
const enriched = modelNodes.map((node): ModelNode => {
|
|
1682
|
+
const baseDecl = baseDeclarations.get(node.modelName);
|
|
1683
|
+
if (!baseDecl) return node;
|
|
1684
|
+
const baseNode = nodeByModel.get(baseDecl.baseName);
|
|
1685
|
+
if (!baseNode) return node;
|
|
1686
|
+
// Single-table inheritance (no own `@@map`) shares the base table; it gets
|
|
1687
|
+
// its columns materialised onto the base instead (see
|
|
1688
|
+
// {@link materializeStiVariantStorageColumns}), never a link column.
|
|
1689
|
+
if (stiVariantNames.has(node.modelName)) return node;
|
|
1690
|
+
const basePrimaryKey = baseNode.id;
|
|
1691
|
+
if (!basePrimaryKey || basePrimaryKey.columns.length === 0) return node;
|
|
1692
|
+
|
|
1693
|
+
const existingColumns = new Set(node.fields.map((field) => field.columnName));
|
|
1694
|
+
const linkFields: FieldNode[] = [];
|
|
1695
|
+
for (const pkColumn of basePrimaryKey.columns) {
|
|
1696
|
+
if (existingColumns.has(pkColumn)) continue;
|
|
1697
|
+
const baseField = baseNode.fields.find(
|
|
1698
|
+
(field): field is FieldNode => 'descriptor' in field && field.columnName === pkColumn,
|
|
1699
|
+
);
|
|
1700
|
+
if (!baseField) continue;
|
|
1701
|
+
linkFields.push({
|
|
1702
|
+
fieldName: baseField.fieldName,
|
|
1703
|
+
columnName: pkColumn,
|
|
1704
|
+
descriptor: baseField.descriptor,
|
|
1705
|
+
nullable: false,
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
if (linkFields.length === 0) return node;
|
|
1709
|
+
|
|
1710
|
+
syntheticPkFieldsByVariant.set(
|
|
1711
|
+
node.modelName,
|
|
1712
|
+
linkFields.map((field) => field.fieldName),
|
|
1713
|
+
);
|
|
1714
|
+
|
|
1715
|
+
const foreignKey: ForeignKeyNode = {
|
|
1716
|
+
columns: basePrimaryKey.columns,
|
|
1717
|
+
references: {
|
|
1718
|
+
model: baseNode.modelName,
|
|
1719
|
+
table: baseNode.tableName,
|
|
1720
|
+
columns: basePrimaryKey.columns,
|
|
1721
|
+
...ifDefined('namespaceId', baseNode.namespaceId),
|
|
1722
|
+
},
|
|
1723
|
+
constraint: true,
|
|
1724
|
+
// The link columns are the variant's own primary key, which already
|
|
1725
|
+
// carries a unique index — a separate FK backing index would be redundant.
|
|
1726
|
+
index: false,
|
|
1727
|
+
// Deleting a base row must delete its variant extension row — classic
|
|
1728
|
+
// multi-table-inheritance semantics.
|
|
1729
|
+
onDelete: 'cascade',
|
|
1730
|
+
};
|
|
1731
|
+
|
|
1732
|
+
return {
|
|
1733
|
+
...node,
|
|
1734
|
+
fields: [...linkFields, ...node.fields],
|
|
1735
|
+
id: { columns: basePrimaryKey.columns },
|
|
1736
|
+
foreignKeys: [...(node.foreignKeys ?? []), foreignKey],
|
|
1737
|
+
};
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
return { modelNodes: enriched, syntheticPkFieldsByVariant };
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* Single-table-inheritance variants (`@@base` with no own `@@map`) share the
|
|
1745
|
+
* base table: `resolvePolymorphism` points the variant's `storage.table` at the
|
|
1746
|
+
* base, and the ORM reads variant-declared fields straight off the base table.
|
|
1747
|
+
* For that to validate and round-trip, the base storage table must physically
|
|
1748
|
+
* carry every STI variant's declared columns. This enriches the base
|
|
1749
|
+
* `ModelNode` with those columns.
|
|
1750
|
+
*
|
|
1751
|
+
* The materialised columns are always nullable in storage: the base table hosts
|
|
1752
|
+
* every variant's rows, so a column a variant declares as required is still
|
|
1753
|
+
* NULL on sibling-variant rows. The variant's domain field keeps its declared
|
|
1754
|
+
* nullability — required-in-domain / nullable-in-storage is the intended STI
|
|
1755
|
+
* shape.
|
|
1756
|
+
*
|
|
1757
|
+
* Collisions (two variants declaring the same column, or a variant column name
|
|
1758
|
+
* clashing with a base column) are resolved skip-if-exists here, mirroring the
|
|
1759
|
+
* MTI link guard; surfacing them as diagnostics is tracked separately
|
|
1760
|
+
* (TML-2827).
|
|
1761
|
+
*/
|
|
1762
|
+
function materializeStiVariantStorageColumns(
|
|
1763
|
+
modelNodes: readonly ModelNode[],
|
|
1764
|
+
baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
|
|
1765
|
+
stiVariantNames: ReadonlySet<string>,
|
|
1766
|
+
): { modelNodes: ModelNode[]; stiBaseFieldsByBase: Map<string, readonly string[]> } {
|
|
1767
|
+
if (stiVariantNames.size === 0) {
|
|
1768
|
+
return { modelNodes: [...modelNodes], stiBaseFieldsByBase: new Map() };
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
|
|
1772
|
+
type StiColumn = ModelNode['fields'][number];
|
|
1773
|
+
const stiColumnsByBase = new Map<string, StiColumn[]>();
|
|
1774
|
+
|
|
1775
|
+
for (const variantName of stiVariantNames) {
|
|
1776
|
+
const variantNode = nodeByModel.get(variantName);
|
|
1777
|
+
const baseDecl = baseDeclarations.get(variantName);
|
|
1778
|
+
if (!variantNode || !baseDecl) continue;
|
|
1779
|
+
const baseNode = nodeByModel.get(baseDecl.baseName);
|
|
1780
|
+
if (!baseNode) continue;
|
|
1781
|
+
|
|
1782
|
+
const baseColumns = new Set(baseNode.fields.map((field) => field.columnName));
|
|
1783
|
+
const claimed = stiColumnsByBase.get(baseDecl.baseName) ?? [];
|
|
1784
|
+
const claimedColumns = new Set(claimed.map((field) => field.columnName));
|
|
1785
|
+
|
|
1786
|
+
for (const field of variantNode.fields) {
|
|
1787
|
+
if (baseColumns.has(field.columnName) || claimedColumns.has(field.columnName)) {
|
|
1788
|
+
continue;
|
|
1789
|
+
}
|
|
1790
|
+
claimedColumns.add(field.columnName);
|
|
1791
|
+
claimed.push({ ...field, nullable: true });
|
|
1792
|
+
}
|
|
1793
|
+
stiColumnsByBase.set(baseDecl.baseName, claimed);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// The materialised columns exist on the base STORAGE table so the variants'
|
|
1797
|
+
// `storage.fields` resolve, but they are NOT base DOMAIN fields — `severity`
|
|
1798
|
+
// belongs to `Bug`, not to `Task`. Report the materialised field names per
|
|
1799
|
+
// base so the domain patch can strip them from the base model (the table
|
|
1800
|
+
// column stays); this is the STI analogue of `syntheticPkFieldsByVariant`.
|
|
1801
|
+
const stiBaseFieldsByBase = new Map<string, readonly string[]>();
|
|
1802
|
+
for (const [baseName, columns] of stiColumnsByBase) {
|
|
1803
|
+
stiBaseFieldsByBase.set(
|
|
1804
|
+
baseName,
|
|
1805
|
+
columns.map((field) => field.fieldName),
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
const enriched = modelNodes.map((node): ModelNode => {
|
|
1810
|
+
// STI variant: contributes a domain model but no storage table of its own.
|
|
1811
|
+
if (stiVariantNames.has(node.modelName)) {
|
|
1812
|
+
return { ...node, sharesBaseTable: true };
|
|
1813
|
+
}
|
|
1814
|
+
const stiColumns = stiColumnsByBase.get(node.modelName);
|
|
1815
|
+
if (!stiColumns || stiColumns.length === 0) return node;
|
|
1816
|
+
return { ...node, fields: [...node.fields, ...stiColumns] };
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
return { modelNodes: enriched, stiBaseFieldsByBase };
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* Drop the storage-only link fields (added by
|
|
1824
|
+
* {@link materializeMtiVariantStorageLinks}) from a variant's domain model, so
|
|
1825
|
+
* the domain surface stays thin while the storage table keeps the link column.
|
|
1826
|
+
*/
|
|
1827
|
+
function stripStorageOnlyDomainFields(
|
|
1828
|
+
model: ContractModel,
|
|
1829
|
+
fieldNames: readonly string[],
|
|
1830
|
+
): ContractModel {
|
|
1831
|
+
if (fieldNames.length === 0) return model;
|
|
1832
|
+
const fields = { ...model.fields };
|
|
1833
|
+
for (const name of fieldNames) delete fields[name];
|
|
1834
|
+
const storage = blindCast<
|
|
1835
|
+
SqlModelStorage,
|
|
1836
|
+
'SQL interpreter domain models always carry SqlModelStorage'
|
|
1837
|
+
>(model.storage);
|
|
1838
|
+
const storageFields = { ...storage.fields };
|
|
1839
|
+
for (const name of fieldNames) delete storageFields[name];
|
|
1840
|
+
return { ...model, fields, storage: { ...storage, fields: storageFields } };
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1429
1843
|
export function interpretPslDocumentToSqlContract(
|
|
1430
1844
|
input: InterpretPslDocumentToSqlContractInput,
|
|
1431
1845
|
): Result<Contract, ContractSourceDiagnostics> {
|
|
@@ -1514,6 +1928,8 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1514
1928
|
const modelNames = new Set(models.map((model) => model.name));
|
|
1515
1929
|
const compositeTypeNames = new Set(compositeTypes.map((ct) => ct.name));
|
|
1516
1930
|
const composedExtensions = new Set(input.composedExtensionPacks ?? []);
|
|
1931
|
+
const composedExtensionContracts: ReadonlyMap<string, Contract> =
|
|
1932
|
+
input.composedExtensionContracts;
|
|
1517
1933
|
const defaultFunctionRegistry: ControlMutationDefaultRegistry =
|
|
1518
1934
|
input.controlMutationDefaults?.defaultFunctionRegistry ?? new Map();
|
|
1519
1935
|
const generatorDescriptors = input.controlMutationDefaults?.generatorDescriptors ?? [];
|
|
@@ -1581,6 +1997,9 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1581
1997
|
const fkRelationMetadata: FkRelationMetadata[] = [];
|
|
1582
1998
|
const backrelationCandidates: ModelBackrelationCandidate[] = [];
|
|
1583
1999
|
const modelResolvedFields = new Map<string, readonly ResolvedField[]>();
|
|
2000
|
+
// Cross-space relation nodes keyed by declaring model name — merged into
|
|
2001
|
+
// modelRelations after local back-relation matching so they bypass that step.
|
|
2002
|
+
const crossSpaceRelationsByModel = new Map<string, RelationNode[]>();
|
|
1584
2003
|
|
|
1585
2004
|
for (const model of models) {
|
|
1586
2005
|
const mapping = modelMappings.get(model.name);
|
|
@@ -1596,6 +2015,7 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1596
2015
|
enumTypeDescriptors: allEnumTypeDescriptors,
|
|
1597
2016
|
namedTypeDescriptors: namedTypeResult.namedTypeDescriptors,
|
|
1598
2017
|
composedExtensions,
|
|
2018
|
+
composedExtensionContracts,
|
|
1599
2019
|
familyId: input.target.familyId,
|
|
1600
2020
|
targetId: input.target.targetId,
|
|
1601
2021
|
authoringContributions: input.authoringContributions,
|
|
@@ -1615,6 +2035,10 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1615
2035
|
fkRelationMetadata.push(...result.fkRelationMetadata);
|
|
1616
2036
|
backrelationCandidates.push(...result.backrelationCandidates);
|
|
1617
2037
|
modelResolvedFields.set(model.name, result.resolvedFields);
|
|
2038
|
+
if (result.crossSpaceRelations.length > 0) {
|
|
2039
|
+
const existing = crossSpaceRelationsByModel.get(model.name) ?? [];
|
|
2040
|
+
crossSpaceRelationsByModel.set(model.name, [...existing, ...result.crossSpaceRelations]);
|
|
2041
|
+
}
|
|
1618
2042
|
}
|
|
1619
2043
|
|
|
1620
2044
|
const { modelRelations, fkRelationsByPair } = indexFkRelations({ fkRelationMetadata });
|
|
@@ -1626,12 +2050,44 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1626
2050
|
sourceId,
|
|
1627
2051
|
});
|
|
1628
2052
|
|
|
2053
|
+
// Merge cross-space relations into modelRelations after local back-relation matching.
|
|
2054
|
+
// Cross-space targets have no local back-relation candidates, so they bypass that step.
|
|
2055
|
+
for (const [modelName, relations] of crossSpaceRelationsByModel) {
|
|
2056
|
+
const existing = modelRelations.get(modelName);
|
|
2057
|
+
if (existing) {
|
|
2058
|
+
existing.push(...relations);
|
|
2059
|
+
} else {
|
|
2060
|
+
modelRelations.set(modelName, [...relations]);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
|
|
1629
2064
|
const { discriminatorDeclarations, baseDeclarations } = collectPolymorphismDeclarations(
|
|
1630
2065
|
models,
|
|
1631
2066
|
sourceId,
|
|
1632
2067
|
diagnostics,
|
|
1633
2068
|
);
|
|
1634
2069
|
|
|
2070
|
+
// A variant with `@@base` but no own `@@map` is single-table inheritance:
|
|
2071
|
+
// it shares the base table. (`@@map` ⇒ multi-table inheritance.) This is the
|
|
2072
|
+
// authoritative STI/MTI signal — the variant's resolved table name is not,
|
|
2073
|
+
// because a no-`@@map` STI variant still gets a `lowerFirst(name)` default
|
|
2074
|
+
// table name that differs from the base before `resolvePolymorphism` rewrites
|
|
2075
|
+
// it onto the base table.
|
|
2076
|
+
const stiVariantNames = new Set<string>();
|
|
2077
|
+
for (const variantName of baseDeclarations.keys()) {
|
|
2078
|
+
const variantMapping = modelMappings.get(variantName);
|
|
2079
|
+
const hasExplicitMap =
|
|
2080
|
+
variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
|
|
2081
|
+
if (!hasExplicitMap) {
|
|
2082
|
+
stiVariantNames.add(variantName);
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
const { modelNodes: mtiLinkedModelNodes, syntheticPkFieldsByVariant } =
|
|
2087
|
+
materializeMtiVariantStorageLinks(modelNodes, baseDeclarations, stiVariantNames);
|
|
2088
|
+
const { modelNodes: stiColumnModelNodes, stiBaseFieldsByBase } =
|
|
2089
|
+
materializeStiVariantStorageColumns(mtiLinkedModelNodes, baseDeclarations, stiVariantNames);
|
|
2090
|
+
|
|
1635
2091
|
const valueObjects = buildValueObjects({
|
|
1636
2092
|
compositeTypes,
|
|
1637
2093
|
enumTypeDescriptors: allEnumTypeDescriptors,
|
|
@@ -1667,7 +2123,7 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1667
2123
|
? { namespaceTypes: namespaceEnumStorageTypes }
|
|
1668
2124
|
: {}),
|
|
1669
2125
|
...ifDefined('createNamespace', input.createNamespace),
|
|
1670
|
-
models:
|
|
2126
|
+
models: stiColumnModelNodes.map((model) => ({
|
|
1671
2127
|
...model,
|
|
1672
2128
|
...(modelRelations.has(model.modelName)
|
|
1673
2129
|
? {
|
|
@@ -1687,7 +2143,7 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1687
2143
|
`duplicate model name "${modelName}" across domain namespaces during PSL interpretation`,
|
|
1688
2144
|
);
|
|
1689
2145
|
}
|
|
1690
|
-
modelsForPatch[modelName] = model
|
|
2146
|
+
modelsForPatch[modelName] = model;
|
|
1691
2147
|
}
|
|
1692
2148
|
}
|
|
1693
2149
|
let patchedModels = patchModelDomainFields(modelsForPatch, modelResolvedFields);
|
|
@@ -1700,7 +2156,9 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1700
2156
|
modelNames,
|
|
1701
2157
|
modelMappings,
|
|
1702
2158
|
modelNamespaceIds,
|
|
1703
|
-
input.target.
|
|
2159
|
+
input.target.defaultNamespaceId,
|
|
2160
|
+
syntheticPkFieldsByVariant,
|
|
2161
|
+
stiBaseFieldsByBase,
|
|
1704
2162
|
sourceId,
|
|
1705
2163
|
polyDiagnostics,
|
|
1706
2164
|
);
|
|
@@ -1736,7 +2194,7 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1736
2194
|
...(namespaceSlice.valueObjects !== undefined
|
|
1737
2195
|
? { valueObjects: namespaceSlice.valueObjects }
|
|
1738
2196
|
: {}),
|
|
1739
|
-
...(namespaceId ===
|
|
2197
|
+
...(namespaceId === input.target.defaultNamespaceId &&
|
|
1740
2198
|
Object.keys(valueObjects).length > 0
|
|
1741
2199
|
? { valueObjects }
|
|
1742
2200
|
: {}),
|