@prisma-next/sql-contract-psl 0.12.0 → 0.13.0-dev.10
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-B_KtZusL.mjs} +439 -52
- package/dist/interpreter-B_KtZusL.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 +552 -52
- 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 +28 -3
- package/src/psl-relation-resolution.ts +6 -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,
|
|
@@ -77,6 +82,8 @@ import {
|
|
|
77
82
|
buildModelMappings,
|
|
78
83
|
collectResolvedFields,
|
|
79
84
|
type ModelNameMapping,
|
|
85
|
+
type ModelNamespaceEntry,
|
|
86
|
+
modelCoordinateKey,
|
|
80
87
|
type ResolvedField,
|
|
81
88
|
} from './psl-field-resolution';
|
|
82
89
|
import {
|
|
@@ -97,6 +104,16 @@ export interface InterpretPslDocumentToSqlContractInput {
|
|
|
97
104
|
readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[];
|
|
98
105
|
readonly controlMutationDefaults?: ControlMutationDefaults;
|
|
99
106
|
readonly authoringContributions?: AuthoringContributions;
|
|
107
|
+
/**
|
|
108
|
+
* Extension contracts keyed by space ID. Required for cross-space FK
|
|
109
|
+
* resolution. A composed space must have an entry here; if the space ID
|
|
110
|
+
* appears in `composedExtensionPacks` but is absent from this map, the
|
|
111
|
+
* interpreter emits `PSL_UNKNOWN_CONTRACT_SPACE` and fails fast — there
|
|
112
|
+
* is no silent fallback. If a space's contract is present but the
|
|
113
|
+
* referenced model or namespace is not found in it, the interpreter
|
|
114
|
+
* emits `PSL_UNKNOWN_CROSS_SPACE_TARGET`.
|
|
115
|
+
*/
|
|
116
|
+
readonly composedExtensionContracts: ReadonlyMap<string, Contract>;
|
|
100
117
|
/**
|
|
101
118
|
* Target-supplied `Namespace` factory threaded into
|
|
102
119
|
* `buildSqlContractFromDefinition` for the contract's
|
|
@@ -229,10 +246,6 @@ const UNSPECIFIED_PSL_NAMESPACE_NAME = '__unspecified__';
|
|
|
229
246
|
* slot empty (which means the late-bound default at the `StorageTable`
|
|
230
247
|
* layer; emitted JSON omits the field).
|
|
231
248
|
*/
|
|
232
|
-
function defaultSqlNamespaceIdForTarget(targetId: string): string {
|
|
233
|
-
return targetId === 'postgres' ? 'public' : UNBOUND_NAMESPACE_ID;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
249
|
function resolveNamespaceIdForSqlTarget(input: {
|
|
237
250
|
readonly bucketName: string;
|
|
238
251
|
readonly targetId: string;
|
|
@@ -583,11 +596,19 @@ interface BuildModelNodeInput {
|
|
|
583
596
|
readonly model: PslModel;
|
|
584
597
|
readonly mapping: ModelNameMapping;
|
|
585
598
|
readonly modelMappings: ReadonlyMap<string, ModelNameMapping>;
|
|
599
|
+
/**
|
|
600
|
+
* Model mappings keyed by `(namespaceId, modelName)` coordinate. Used to
|
|
601
|
+
* resolve a namespace-qualified relation target (`auth.User`) to the exact
|
|
602
|
+
* model even when the bare name is shared across namespaces.
|
|
603
|
+
*/
|
|
604
|
+
readonly modelMappingsByCoordinate: ReadonlyMap<string, ModelNameMapping>;
|
|
586
605
|
readonly modelNames: Set<string>;
|
|
587
606
|
readonly compositeTypeNames: ReadonlySet<string>;
|
|
588
607
|
readonly enumTypeDescriptors: Map<string, ColumnDescriptor>;
|
|
589
608
|
readonly namedTypeDescriptors: Map<string, ColumnDescriptor>;
|
|
590
609
|
readonly composedExtensions: Set<string>;
|
|
610
|
+
/** Extension contracts keyed by space ID for cross-space FK table-name resolution. */
|
|
611
|
+
readonly composedExtensionContracts: ReadonlyMap<string, Contract>;
|
|
591
612
|
readonly familyId: string;
|
|
592
613
|
readonly targetId: string;
|
|
593
614
|
readonly authoringContributions: AuthoringContributions | undefined;
|
|
@@ -605,6 +626,8 @@ interface BuildModelNodeResult {
|
|
|
605
626
|
readonly fkRelationMetadata: FkRelationMetadata[];
|
|
606
627
|
readonly backrelationCandidates: ModelBackrelationCandidate[];
|
|
607
628
|
readonly resolvedFields: readonly ResolvedField[];
|
|
629
|
+
/** Cross-contract-space relation nodes that bypass the local back-relation matching. */
|
|
630
|
+
readonly crossSpaceRelations: RelationNode[];
|
|
608
631
|
}
|
|
609
632
|
|
|
610
633
|
function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult {
|
|
@@ -647,6 +670,8 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
647
670
|
: undefined;
|
|
648
671
|
const hasInlinePrimaryKey = primaryKey !== undefined;
|
|
649
672
|
let blockPrimaryKeyDeclared = false;
|
|
673
|
+
let controlPolicyDeclared = false;
|
|
674
|
+
let controlPolicy: ControlPolicy | undefined;
|
|
650
675
|
|
|
651
676
|
const resultBackrelationCandidates: ModelBackrelationCandidate[] = [];
|
|
652
677
|
for (const field of model.fields) {
|
|
@@ -733,6 +758,27 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
733
758
|
if (modelAttribute.name === 'discriminator' || modelAttribute.name === 'base') {
|
|
734
759
|
continue;
|
|
735
760
|
}
|
|
761
|
+
if (modelAttribute.name === 'control') {
|
|
762
|
+
if (controlPolicyDeclared) {
|
|
763
|
+
diagnostics.push({
|
|
764
|
+
code: 'PSL_DUPLICATE_ATTRIBUTE',
|
|
765
|
+
message: `\`@@control\` declared more than once on model "${model.name}".`,
|
|
766
|
+
sourceId,
|
|
767
|
+
span: modelAttribute.span,
|
|
768
|
+
});
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
controlPolicyDeclared = true;
|
|
772
|
+
const parsed = parseControlPolicyAttribute({
|
|
773
|
+
attribute: modelAttribute,
|
|
774
|
+
sourceId,
|
|
775
|
+
diagnostics,
|
|
776
|
+
});
|
|
777
|
+
if (parsed !== undefined) {
|
|
778
|
+
controlPolicy = parsed;
|
|
779
|
+
}
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
736
782
|
const attributeLabel = `Model "${model.name}" @@${modelAttribute.name}`;
|
|
737
783
|
if (modelAttribute.name === 'id') {
|
|
738
784
|
if (blockPrimaryKeyDeclared) {
|
|
@@ -937,13 +983,183 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
937
983
|
}
|
|
938
984
|
|
|
939
985
|
const resultFkRelationMetadata: FkRelationMetadata[] = [];
|
|
986
|
+
const resultCrossSpaceRelations: RelationNode[] = [];
|
|
940
987
|
for (const relationAttribute of relationAttributes) {
|
|
988
|
+
const {
|
|
989
|
+
typeName: fieldTypeName,
|
|
990
|
+
typeNamespaceId: fieldTypeNamespaceId,
|
|
991
|
+
typeContractSpaceId: fieldTypeContractSpaceId,
|
|
992
|
+
} = relationAttribute.field;
|
|
993
|
+
|
|
941
994
|
if (relationAttribute.field.list) {
|
|
995
|
+
// F-list: cross-space list relations are explicitly unsupported (Option B does not
|
|
996
|
+
// navigate, so a list target makes no sense to carry). Emit a diagnostic instead of
|
|
997
|
+
// silently dropping the field — the author needs to know the field was ignored.
|
|
998
|
+
if (fieldTypeContractSpaceId !== undefined) {
|
|
999
|
+
diagnostics.push({
|
|
1000
|
+
code: 'PSL_UNSUPPORTED_CROSS_SPACE_LIST',
|
|
1001
|
+
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.`,
|
|
1002
|
+
sourceId,
|
|
1003
|
+
span: relationAttribute.field.span,
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Cross-contract-space relation: the target model lives in a different contract space
|
|
1010
|
+
// identified by `typeContractSpaceId` (e.g. `supabase:auth.User`).
|
|
1011
|
+
if (fieldTypeContractSpaceId !== undefined) {
|
|
1012
|
+
// Fail fast if the space has no entry in composedExtensionContracts (AC5 PSL half).
|
|
1013
|
+
const extContractForSpace = input.composedExtensionContracts.get(fieldTypeContractSpaceId);
|
|
1014
|
+
if (extContractForSpace === undefined) {
|
|
1015
|
+
diagnostics.push({
|
|
1016
|
+
code: 'PSL_UNKNOWN_CONTRACT_SPACE',
|
|
1017
|
+
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.`,
|
|
1018
|
+
sourceId,
|
|
1019
|
+
span: relationAttribute.field.span,
|
|
1020
|
+
data: { space: fieldTypeContractSpaceId, suggestedPack: fieldTypeContractSpaceId },
|
|
1021
|
+
});
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const parsedRelation = parseRelationAttribute({
|
|
1026
|
+
attribute: relationAttribute.relation,
|
|
1027
|
+
modelName: model.name,
|
|
1028
|
+
fieldName: relationAttribute.field.name,
|
|
1029
|
+
sourceId,
|
|
1030
|
+
diagnostics,
|
|
1031
|
+
});
|
|
1032
|
+
if (!parsedRelation) {
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
if (!parsedRelation.fields || !parsedRelation.references) {
|
|
1036
|
+
diagnostics.push({
|
|
1037
|
+
code: 'PSL_INVALID_RELATION_ATTRIBUTE',
|
|
1038
|
+
message: `Relation field "${model.name}.${relationAttribute.field.name}" requires fields and references arguments`,
|
|
1039
|
+
sourceId,
|
|
1040
|
+
span: relationAttribute.relation.span,
|
|
1041
|
+
});
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const localColumns = mapFieldNamesToColumns({
|
|
1046
|
+
modelName: model.name,
|
|
1047
|
+
fieldNames: parsedRelation.fields,
|
|
1048
|
+
mapping,
|
|
1049
|
+
sourceId,
|
|
1050
|
+
diagnostics,
|
|
1051
|
+
span: relationAttribute.relation.span,
|
|
1052
|
+
entityLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
|
|
1053
|
+
});
|
|
1054
|
+
if (!localColumns) {
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// For cross-space references the `references` list provides field names from the remote
|
|
1059
|
+
// model. Since the interpreter has no access to the extension contract, these field names
|
|
1060
|
+
// are treated as column names directly (matching the TS builder's cross-space path).
|
|
1061
|
+
const referencedColumns = parsedRelation.references;
|
|
1062
|
+
|
|
1063
|
+
if (localColumns.length !== referencedColumns.length) {
|
|
1064
|
+
diagnostics.push({
|
|
1065
|
+
code: 'PSL_INVALID_RELATION_ATTRIBUTE',
|
|
1066
|
+
message: `Relation field "${model.name}.${relationAttribute.field.name}" must provide the same number of fields and references`,
|
|
1067
|
+
sourceId,
|
|
1068
|
+
span: relationAttribute.relation.span,
|
|
1069
|
+
});
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const onDelete = parsedRelation.onDelete
|
|
1074
|
+
? normalizeReferentialAction({
|
|
1075
|
+
modelName: model.name,
|
|
1076
|
+
fieldName: relationAttribute.field.name,
|
|
1077
|
+
actionName: 'onDelete',
|
|
1078
|
+
actionToken: parsedRelation.onDelete,
|
|
1079
|
+
sourceId,
|
|
1080
|
+
span: relationAttribute.field.span,
|
|
1081
|
+
diagnostics,
|
|
1082
|
+
})
|
|
1083
|
+
: undefined;
|
|
1084
|
+
const onUpdate = parsedRelation.onUpdate
|
|
1085
|
+
? normalizeReferentialAction({
|
|
1086
|
+
modelName: model.name,
|
|
1087
|
+
fieldName: relationAttribute.field.name,
|
|
1088
|
+
actionName: 'onUpdate',
|
|
1089
|
+
actionToken: parsedRelation.onUpdate,
|
|
1090
|
+
sourceId,
|
|
1091
|
+
span: relationAttribute.field.span,
|
|
1092
|
+
diagnostics,
|
|
1093
|
+
})
|
|
1094
|
+
: undefined;
|
|
1095
|
+
|
|
1096
|
+
// Target namespace: use the colon-prefix namespace qualifier, or `__unbound__` when the
|
|
1097
|
+
// no-namespace form is used (e.g. `supabase:User` → AC3).
|
|
1098
|
+
const crossTargetNamespaceId = fieldTypeNamespaceId ?? '__unbound__';
|
|
1099
|
+
|
|
1100
|
+
// Target table name: resolved from the extension contract. The get() check above
|
|
1101
|
+
// guarantees extContractForSpace is defined here; if the model or namespace is not
|
|
1102
|
+
// found in it, emit PSL_UNKNOWN_CROSS_SPACE_TARGET (user typo).
|
|
1103
|
+
const extContract = extContractForSpace;
|
|
1104
|
+
const resolvedTable =
|
|
1105
|
+
extContract.domain.namespaces[crossTargetNamespaceId]?.models[fieldTypeName]?.storage[
|
|
1106
|
+
'table'
|
|
1107
|
+
];
|
|
1108
|
+
if (typeof resolvedTable !== 'string') {
|
|
1109
|
+
const availableModels =
|
|
1110
|
+
Object.keys(extContract.domain.namespaces[crossTargetNamespaceId]?.models ?? {}).join(
|
|
1111
|
+
', ',
|
|
1112
|
+
) || '(none)';
|
|
1113
|
+
diagnostics.push({
|
|
1114
|
+
code: 'PSL_UNKNOWN_CROSS_SPACE_TARGET',
|
|
1115
|
+
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}`,
|
|
1116
|
+
sourceId,
|
|
1117
|
+
span: relationAttribute.field.span,
|
|
1118
|
+
data: {
|
|
1119
|
+
space: fieldTypeContractSpaceId,
|
|
1120
|
+
namespace: crossTargetNamespaceId,
|
|
1121
|
+
model: fieldTypeName,
|
|
1122
|
+
},
|
|
1123
|
+
});
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
const crossTargetTableName = resolvedTable;
|
|
1127
|
+
|
|
1128
|
+
foreignKeyNodes.push({
|
|
1129
|
+
columns: localColumns,
|
|
1130
|
+
references: {
|
|
1131
|
+
model: fieldTypeName,
|
|
1132
|
+
table: crossTargetTableName,
|
|
1133
|
+
columns: referencedColumns,
|
|
1134
|
+
namespaceId: crossTargetNamespaceId,
|
|
1135
|
+
spaceId: fieldTypeContractSpaceId,
|
|
1136
|
+
},
|
|
1137
|
+
...ifDefined('name', parsedRelation.constraintName),
|
|
1138
|
+
...ifDefined('onDelete', onDelete),
|
|
1139
|
+
...ifDefined('onUpdate', onUpdate),
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
// Build the cross-space RelationNode directly (no local back-relation candidate).
|
|
1143
|
+
// `buildSqlContractFromDefinition` recognises `spaceId` on a RelationNode and routes it
|
|
1144
|
+
// through the cross-space domain-relation path (produces a non-navigable CrossReference).
|
|
1145
|
+
resultCrossSpaceRelations.push({
|
|
1146
|
+
fieldName: relationAttribute.field.name,
|
|
1147
|
+
toModel: fieldTypeName,
|
|
1148
|
+
toTable: crossTargetTableName,
|
|
1149
|
+
cardinality: 'N:1',
|
|
1150
|
+
spaceId: fieldTypeContractSpaceId,
|
|
1151
|
+
namespaceId: crossTargetNamespaceId,
|
|
1152
|
+
on: {
|
|
1153
|
+
parentTable: tableName,
|
|
1154
|
+
parentColumns: localColumns,
|
|
1155
|
+
childTable: crossTargetTableName,
|
|
1156
|
+
childColumns: referencedColumns,
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
|
|
942
1160
|
continue;
|
|
943
1161
|
}
|
|
944
1162
|
|
|
945
|
-
const { typeName: fieldTypeName, typeNamespaceId: fieldTypeNamespaceId } =
|
|
946
|
-
relationAttribute.field;
|
|
947
1163
|
const qualifiedTypeName = fieldTypeNamespaceId
|
|
948
1164
|
? `${fieldTypeNamespaceId}.${fieldTypeName}`
|
|
949
1165
|
: fieldTypeName;
|
|
@@ -958,19 +1174,23 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
958
1174
|
continue;
|
|
959
1175
|
}
|
|
960
1176
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
fieldTypeNamespaceId === 'unbound'
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
1177
|
+
const normalizedQualifier =
|
|
1178
|
+
fieldTypeNamespaceId === undefined
|
|
1179
|
+
? undefined
|
|
1180
|
+
: fieldTypeNamespaceId === 'unbound'
|
|
1181
|
+
? '__unbound__'
|
|
1182
|
+
: fieldTypeNamespaceId;
|
|
1183
|
+
if (
|
|
1184
|
+
normalizedQualifier !== undefined &&
|
|
1185
|
+
!input.modelMappingsByCoordinate.has(modelCoordinateKey(normalizedQualifier, fieldTypeName))
|
|
1186
|
+
) {
|
|
1187
|
+
diagnostics.push({
|
|
1188
|
+
code: 'PSL_INVALID_RELATION_TARGET',
|
|
1189
|
+
message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${qualifiedTypeName}"`,
|
|
1190
|
+
sourceId,
|
|
1191
|
+
span: relationAttribute.field.span,
|
|
1192
|
+
});
|
|
1193
|
+
continue;
|
|
974
1194
|
}
|
|
975
1195
|
|
|
976
1196
|
const parsedRelation = parseRelationAttribute({
|
|
@@ -993,7 +1213,12 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
993
1213
|
continue;
|
|
994
1214
|
}
|
|
995
1215
|
|
|
996
|
-
const targetMapping =
|
|
1216
|
+
const targetMapping =
|
|
1217
|
+
normalizedQualifier !== undefined
|
|
1218
|
+
? input.modelMappingsByCoordinate.get(
|
|
1219
|
+
modelCoordinateKey(normalizedQualifier, fieldTypeName),
|
|
1220
|
+
)
|
|
1221
|
+
: input.modelMappings.get(fieldTypeName);
|
|
997
1222
|
if (!targetMapping) {
|
|
998
1223
|
diagnostics.push({
|
|
999
1224
|
code: 'PSL_INVALID_RELATION_TARGET',
|
|
@@ -1061,7 +1286,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
1061
1286
|
})
|
|
1062
1287
|
: undefined;
|
|
1063
1288
|
|
|
1064
|
-
const targetNamespaceId =
|
|
1289
|
+
const targetNamespaceId =
|
|
1290
|
+
normalizedQualifier !== undefined
|
|
1291
|
+
? normalizedQualifier
|
|
1292
|
+
: input.modelNamespaceIds.get(targetMapping.model.name);
|
|
1065
1293
|
foreignKeyNodes.push({
|
|
1066
1294
|
columns: localColumns,
|
|
1067
1295
|
references: {
|
|
@@ -1081,6 +1309,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
1081
1309
|
declaringTableName: tableName,
|
|
1082
1310
|
targetModelName: targetMapping.model.name,
|
|
1083
1311
|
targetTableName: targetMapping.tableName,
|
|
1312
|
+
...ifDefined('targetNamespaceId', targetNamespaceId),
|
|
1084
1313
|
...ifDefined('relationName', parsedRelation.relationName),
|
|
1085
1314
|
localColumns,
|
|
1086
1315
|
referencedColumns,
|
|
@@ -1103,8 +1332,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
1103
1332
|
...(uniqueConstraints.length > 0 ? { uniques: uniqueConstraints } : {}),
|
|
1104
1333
|
...(indexNodes.length > 0 ? { indexes: indexNodes } : {}),
|
|
1105
1334
|
...(foreignKeyNodes.length > 0 ? { foreignKeys: foreignKeyNodes } : {}),
|
|
1335
|
+
...ifDefined('control', controlPolicy),
|
|
1106
1336
|
},
|
|
1107
1337
|
fkRelationMetadata: resultFkRelationMetadata,
|
|
1338
|
+
crossSpaceRelations: resultCrossSpaceRelations,
|
|
1108
1339
|
backrelationCandidates: resultBackrelationCandidates,
|
|
1109
1340
|
resolvedFields,
|
|
1110
1341
|
};
|
|
@@ -1310,12 +1541,31 @@ function resolvePolymorphism(
|
|
|
1310
1541
|
modelNames: Set<string>,
|
|
1311
1542
|
modelMappings: ReadonlyMap<string, ModelNameMapping>,
|
|
1312
1543
|
modelNamespaceIds: ReadonlyMap<string, string>,
|
|
1313
|
-
|
|
1544
|
+
defaultNamespaceId: string,
|
|
1545
|
+
syntheticPkFieldsByVariant: ReadonlyMap<string, readonly string[]>,
|
|
1546
|
+
stiBaseFieldsByBase: ReadonlyMap<string, readonly string[]>,
|
|
1314
1547
|
sourceId: string,
|
|
1315
1548
|
diagnostics: ContractSourceDiagnostic[],
|
|
1316
1549
|
): Record<string, ContractModel> {
|
|
1317
1550
|
let patched = models;
|
|
1318
1551
|
|
|
1552
|
+
const coordinateFor = (modelName: string): string =>
|
|
1553
|
+
modelCoordinateKey(modelNamespaceIds.get(modelName) ?? defaultNamespaceId, modelName);
|
|
1554
|
+
|
|
1555
|
+
// STI variant columns were materialised onto the base storage table so the
|
|
1556
|
+
// variants' `storage.fields` resolve. They are storage-only on the base — the
|
|
1557
|
+
// domain field belongs to the variant — so strip them from the base model's
|
|
1558
|
+
// domain + storage field maps (the table column, built upstream, stays).
|
|
1559
|
+
for (const [baseName, fieldNames] of stiBaseFieldsByBase) {
|
|
1560
|
+
const baseKey = coordinateFor(baseName);
|
|
1561
|
+
const baseModel = patched[baseKey];
|
|
1562
|
+
if (!baseModel || fieldNames.length === 0) continue;
|
|
1563
|
+
patched = {
|
|
1564
|
+
...patched,
|
|
1565
|
+
[baseKey]: stripStorageOnlyDomainFields(baseModel, fieldNames),
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1319
1569
|
for (const [modelName, decl] of discriminatorDeclarations) {
|
|
1320
1570
|
if (baseDeclarations.has(modelName)) {
|
|
1321
1571
|
diagnostics.push({
|
|
@@ -1327,7 +1577,7 @@ function resolvePolymorphism(
|
|
|
1327
1577
|
continue;
|
|
1328
1578
|
}
|
|
1329
1579
|
|
|
1330
|
-
const model = patched[modelName];
|
|
1580
|
+
const model = patched[coordinateFor(modelName)];
|
|
1331
1581
|
if (!model) continue;
|
|
1332
1582
|
|
|
1333
1583
|
if (!Object.hasOwn(model.fields, decl.fieldName)) {
|
|
@@ -1372,7 +1622,7 @@ function resolvePolymorphism(
|
|
|
1372
1622
|
|
|
1373
1623
|
patched = {
|
|
1374
1624
|
...patched,
|
|
1375
|
-
[modelName]: { ...model, discriminator: { field: decl.fieldName }, variants },
|
|
1625
|
+
[coordinateFor(modelName)]: { ...model, discriminator: { field: decl.fieldName }, variants },
|
|
1376
1626
|
};
|
|
1377
1627
|
}
|
|
1378
1628
|
|
|
@@ -1401,7 +1651,7 @@ function resolvePolymorphism(
|
|
|
1401
1651
|
continue;
|
|
1402
1652
|
}
|
|
1403
1653
|
|
|
1404
|
-
const variantModel = patched[variantName];
|
|
1654
|
+
const variantModel = patched[coordinateFor(variantName)];
|
|
1405
1655
|
if (!variantModel) continue;
|
|
1406
1656
|
|
|
1407
1657
|
const baseMapping = modelMappings.get(baseDecl.baseName);
|
|
@@ -1410,22 +1660,211 @@ function resolvePolymorphism(
|
|
|
1410
1660
|
variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
|
|
1411
1661
|
const resolvedTable = hasExplicitMap ? variantMapping?.tableName : baseMapping?.tableName;
|
|
1412
1662
|
|
|
1663
|
+
const patchedVariant: ContractModel = {
|
|
1664
|
+
...variantModel,
|
|
1665
|
+
base: crossRef(
|
|
1666
|
+
baseDecl.baseName,
|
|
1667
|
+
modelNamespaceIds.get(baseDecl.baseName) ?? defaultNamespaceId,
|
|
1668
|
+
),
|
|
1669
|
+
...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}),
|
|
1670
|
+
};
|
|
1671
|
+
|
|
1413
1672
|
patched = {
|
|
1414
1673
|
...patched,
|
|
1415
|
-
[variantName]:
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
modelNamespaceIds.get(baseDecl.baseName) ?? defaultSqlNamespaceIdForTarget(targetId),
|
|
1420
|
-
),
|
|
1421
|
-
...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}),
|
|
1422
|
-
},
|
|
1674
|
+
[coordinateFor(variantName)]: stripStorageOnlyDomainFields(
|
|
1675
|
+
patchedVariant,
|
|
1676
|
+
syntheticPkFieldsByVariant.get(variantName) ?? [],
|
|
1677
|
+
),
|
|
1423
1678
|
};
|
|
1424
1679
|
}
|
|
1425
1680
|
|
|
1426
1681
|
return patched;
|
|
1427
1682
|
}
|
|
1428
1683
|
|
|
1684
|
+
/**
|
|
1685
|
+
* Multi-table-inheritance variants (`@@base` + their own `@@map`) live in a
|
|
1686
|
+
* separate table from their base. The ORM joins that table to the base on the
|
|
1687
|
+
* shared primary key (`base.id = variant.id`), so the variant storage table
|
|
1688
|
+
* must carry the base PK column even though the variant domain model declares
|
|
1689
|
+
* only its own fields. This enriches each MTI variant's `ModelNode` with that
|
|
1690
|
+
* link column, a primary key on it, and a FK back to the base table.
|
|
1691
|
+
*
|
|
1692
|
+
* The link column is reported back per variant in `syntheticPkFieldsByVariant`
|
|
1693
|
+
* so the domain-model patch can drop it again — keeping the variant's domain
|
|
1694
|
+
* surface thin (its create/read inputs don't gain a redundant `id`) while the
|
|
1695
|
+
* storage table stays joinable. Single-table-inheritance variants (no own
|
|
1696
|
+
* table) are left untouched.
|
|
1697
|
+
*/
|
|
1698
|
+
function materializeMtiVariantStorageLinks(
|
|
1699
|
+
modelNodes: readonly ModelNode[],
|
|
1700
|
+
baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
|
|
1701
|
+
stiVariantNames: ReadonlySet<string>,
|
|
1702
|
+
): { modelNodes: ModelNode[]; syntheticPkFieldsByVariant: Map<string, readonly string[]> } {
|
|
1703
|
+
const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
|
|
1704
|
+
const syntheticPkFieldsByVariant = new Map<string, readonly string[]>();
|
|
1705
|
+
|
|
1706
|
+
const enriched = modelNodes.map((node): ModelNode => {
|
|
1707
|
+
const baseDecl = baseDeclarations.get(node.modelName);
|
|
1708
|
+
if (!baseDecl) return node;
|
|
1709
|
+
const baseNode = nodeByModel.get(baseDecl.baseName);
|
|
1710
|
+
if (!baseNode) return node;
|
|
1711
|
+
// Single-table inheritance (no own `@@map`) shares the base table; it gets
|
|
1712
|
+
// its columns materialised onto the base instead (see
|
|
1713
|
+
// {@link materializeStiVariantStorageColumns}), never a link column.
|
|
1714
|
+
if (stiVariantNames.has(node.modelName)) return node;
|
|
1715
|
+
const basePrimaryKey = baseNode.id;
|
|
1716
|
+
if (!basePrimaryKey || basePrimaryKey.columns.length === 0) return node;
|
|
1717
|
+
|
|
1718
|
+
const existingColumns = new Set(node.fields.map((field) => field.columnName));
|
|
1719
|
+
const linkFields: FieldNode[] = [];
|
|
1720
|
+
for (const pkColumn of basePrimaryKey.columns) {
|
|
1721
|
+
if (existingColumns.has(pkColumn)) continue;
|
|
1722
|
+
const baseField = baseNode.fields.find(
|
|
1723
|
+
(field): field is FieldNode => 'descriptor' in field && field.columnName === pkColumn,
|
|
1724
|
+
);
|
|
1725
|
+
if (!baseField) continue;
|
|
1726
|
+
linkFields.push({
|
|
1727
|
+
fieldName: baseField.fieldName,
|
|
1728
|
+
columnName: pkColumn,
|
|
1729
|
+
descriptor: baseField.descriptor,
|
|
1730
|
+
nullable: false,
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
if (linkFields.length === 0) return node;
|
|
1734
|
+
|
|
1735
|
+
syntheticPkFieldsByVariant.set(
|
|
1736
|
+
node.modelName,
|
|
1737
|
+
linkFields.map((field) => field.fieldName),
|
|
1738
|
+
);
|
|
1739
|
+
|
|
1740
|
+
const foreignKey: ForeignKeyNode = {
|
|
1741
|
+
columns: basePrimaryKey.columns,
|
|
1742
|
+
references: {
|
|
1743
|
+
model: baseNode.modelName,
|
|
1744
|
+
table: baseNode.tableName,
|
|
1745
|
+
columns: basePrimaryKey.columns,
|
|
1746
|
+
...ifDefined('namespaceId', baseNode.namespaceId),
|
|
1747
|
+
},
|
|
1748
|
+
constraint: true,
|
|
1749
|
+
// The link columns are the variant's own primary key, which already
|
|
1750
|
+
// carries a unique index — a separate FK backing index would be redundant.
|
|
1751
|
+
index: false,
|
|
1752
|
+
// Deleting a base row must delete its variant extension row — classic
|
|
1753
|
+
// multi-table-inheritance semantics.
|
|
1754
|
+
onDelete: 'cascade',
|
|
1755
|
+
};
|
|
1756
|
+
|
|
1757
|
+
return {
|
|
1758
|
+
...node,
|
|
1759
|
+
fields: [...linkFields, ...node.fields],
|
|
1760
|
+
id: { columns: basePrimaryKey.columns },
|
|
1761
|
+
foreignKeys: [...(node.foreignKeys ?? []), foreignKey],
|
|
1762
|
+
};
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
return { modelNodes: enriched, syntheticPkFieldsByVariant };
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* Single-table-inheritance variants (`@@base` with no own `@@map`) share the
|
|
1770
|
+
* base table: `resolvePolymorphism` points the variant's `storage.table` at the
|
|
1771
|
+
* base, and the ORM reads variant-declared fields straight off the base table.
|
|
1772
|
+
* For that to validate and round-trip, the base storage table must physically
|
|
1773
|
+
* carry every STI variant's declared columns. This enriches the base
|
|
1774
|
+
* `ModelNode` with those columns.
|
|
1775
|
+
*
|
|
1776
|
+
* The materialised columns are always nullable in storage: the base table hosts
|
|
1777
|
+
* every variant's rows, so a column a variant declares as required is still
|
|
1778
|
+
* NULL on sibling-variant rows. The variant's domain field keeps its declared
|
|
1779
|
+
* nullability — required-in-domain / nullable-in-storage is the intended STI
|
|
1780
|
+
* shape.
|
|
1781
|
+
*
|
|
1782
|
+
* Collisions (two variants declaring the same column, or a variant column name
|
|
1783
|
+
* clashing with a base column) are resolved skip-if-exists here, mirroring the
|
|
1784
|
+
* MTI link guard; surfacing them as diagnostics is tracked separately
|
|
1785
|
+
* (TML-2827).
|
|
1786
|
+
*/
|
|
1787
|
+
function materializeStiVariantStorageColumns(
|
|
1788
|
+
modelNodes: readonly ModelNode[],
|
|
1789
|
+
baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
|
|
1790
|
+
stiVariantNames: ReadonlySet<string>,
|
|
1791
|
+
): { modelNodes: ModelNode[]; stiBaseFieldsByBase: Map<string, readonly string[]> } {
|
|
1792
|
+
if (stiVariantNames.size === 0) {
|
|
1793
|
+
return { modelNodes: [...modelNodes], stiBaseFieldsByBase: new Map() };
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
|
|
1797
|
+
type StiColumn = ModelNode['fields'][number];
|
|
1798
|
+
const stiColumnsByBase = new Map<string, StiColumn[]>();
|
|
1799
|
+
|
|
1800
|
+
for (const variantName of stiVariantNames) {
|
|
1801
|
+
const variantNode = nodeByModel.get(variantName);
|
|
1802
|
+
const baseDecl = baseDeclarations.get(variantName);
|
|
1803
|
+
if (!variantNode || !baseDecl) continue;
|
|
1804
|
+
const baseNode = nodeByModel.get(baseDecl.baseName);
|
|
1805
|
+
if (!baseNode) continue;
|
|
1806
|
+
|
|
1807
|
+
const baseColumns = new Set(baseNode.fields.map((field) => field.columnName));
|
|
1808
|
+
const claimed = stiColumnsByBase.get(baseDecl.baseName) ?? [];
|
|
1809
|
+
const claimedColumns = new Set(claimed.map((field) => field.columnName));
|
|
1810
|
+
|
|
1811
|
+
for (const field of variantNode.fields) {
|
|
1812
|
+
if (baseColumns.has(field.columnName) || claimedColumns.has(field.columnName)) {
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
claimedColumns.add(field.columnName);
|
|
1816
|
+
claimed.push({ ...field, nullable: true });
|
|
1817
|
+
}
|
|
1818
|
+
stiColumnsByBase.set(baseDecl.baseName, claimed);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// The materialised columns exist on the base STORAGE table so the variants'
|
|
1822
|
+
// `storage.fields` resolve, but they are NOT base DOMAIN fields — `severity`
|
|
1823
|
+
// belongs to `Bug`, not to `Task`. Report the materialised field names per
|
|
1824
|
+
// base so the domain patch can strip them from the base model (the table
|
|
1825
|
+
// column stays); this is the STI analogue of `syntheticPkFieldsByVariant`.
|
|
1826
|
+
const stiBaseFieldsByBase = new Map<string, readonly string[]>();
|
|
1827
|
+
for (const [baseName, columns] of stiColumnsByBase) {
|
|
1828
|
+
stiBaseFieldsByBase.set(
|
|
1829
|
+
baseName,
|
|
1830
|
+
columns.map((field) => field.fieldName),
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
const enriched = modelNodes.map((node): ModelNode => {
|
|
1835
|
+
// STI variant: contributes a domain model but no storage table of its own.
|
|
1836
|
+
if (stiVariantNames.has(node.modelName)) {
|
|
1837
|
+
return { ...node, sharesBaseTable: true };
|
|
1838
|
+
}
|
|
1839
|
+
const stiColumns = stiColumnsByBase.get(node.modelName);
|
|
1840
|
+
if (!stiColumns || stiColumns.length === 0) return node;
|
|
1841
|
+
return { ...node, fields: [...node.fields, ...stiColumns] };
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
return { modelNodes: enriched, stiBaseFieldsByBase };
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
/**
|
|
1848
|
+
* Drop the storage-only link fields (added by
|
|
1849
|
+
* {@link materializeMtiVariantStorageLinks}) from a variant's domain model, so
|
|
1850
|
+
* the domain surface stays thin while the storage table keeps the link column.
|
|
1851
|
+
*/
|
|
1852
|
+
function stripStorageOnlyDomainFields(
|
|
1853
|
+
model: ContractModel,
|
|
1854
|
+
fieldNames: readonly string[],
|
|
1855
|
+
): ContractModel {
|
|
1856
|
+
if (fieldNames.length === 0) return model;
|
|
1857
|
+
const fields = { ...model.fields };
|
|
1858
|
+
for (const name of fieldNames) delete fields[name];
|
|
1859
|
+
const storage = blindCast<
|
|
1860
|
+
SqlModelStorage,
|
|
1861
|
+
'SQL interpreter domain models always carry SqlModelStorage'
|
|
1862
|
+
>(model.storage);
|
|
1863
|
+
const storageFields = { ...storage.fields };
|
|
1864
|
+
for (const name of fieldNames) delete storageFields[name];
|
|
1865
|
+
return { ...model, fields, storage: { ...storage, fields: storageFields } };
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1429
1868
|
export function interpretPslDocumentToSqlContract(
|
|
1430
1869
|
input: InterpretPslDocumentToSqlContractInput,
|
|
1431
1870
|
): Result<Contract, ContractSourceDiagnostics> {
|
|
@@ -1469,6 +1908,7 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1469
1908
|
// remains the input to the rest of the interpreter so non-namespace
|
|
1470
1909
|
// concerns stay structurally identical to before.
|
|
1471
1910
|
const models: PslModel[] = [];
|
|
1911
|
+
const modelEntries: ModelNamespaceEntry[] = [];
|
|
1472
1912
|
const modelNamespaceIds = new Map<string, string>();
|
|
1473
1913
|
for (const namespace of input.document.ast.namespaces) {
|
|
1474
1914
|
const resolvedNamespaceId = resolveNamespaceIdForSqlTarget({
|
|
@@ -1477,11 +1917,13 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1477
1917
|
});
|
|
1478
1918
|
for (const model of namespace.models) {
|
|
1479
1919
|
models.push(model);
|
|
1920
|
+
modelEntries.push({ model, namespaceId: resolvedNamespaceId });
|
|
1480
1921
|
if (resolvedNamespaceId !== undefined) {
|
|
1481
1922
|
modelNamespaceIds.set(model.name, resolvedNamespaceId);
|
|
1482
1923
|
}
|
|
1483
1924
|
}
|
|
1484
1925
|
}
|
|
1926
|
+
const defaultNamespaceId = input.target.defaultNamespaceId;
|
|
1485
1927
|
// Top-level enums (the __unspecified__ bucket) route to `storageTypes`;
|
|
1486
1928
|
// enums inside a named namespace block route to `namespaceTypes[nsId]`.
|
|
1487
1929
|
const topLevelEnums = input.document.ast.namespaces
|
|
@@ -1514,6 +1956,8 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1514
1956
|
const modelNames = new Set(models.map((model) => model.name));
|
|
1515
1957
|
const compositeTypeNames = new Set(compositeTypes.map((ct) => ct.name));
|
|
1516
1958
|
const composedExtensions = new Set(input.composedExtensionPacks ?? []);
|
|
1959
|
+
const composedExtensionContracts: ReadonlyMap<string, Contract> =
|
|
1960
|
+
input.composedExtensionContracts;
|
|
1517
1961
|
const defaultFunctionRegistry: ControlMutationDefaultRegistry =
|
|
1518
1962
|
input.controlMutationDefaults?.defaultFunctionRegistry ?? new Map();
|
|
1519
1963
|
const generatorDescriptors = input.controlMutationDefaults?.generatorDescriptors ?? [];
|
|
@@ -1576,14 +2020,31 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1576
2020
|
|
|
1577
2021
|
const storageTypes = { ...enumResult.storageTypes, ...namedTypeResult.storageTypes };
|
|
1578
2022
|
|
|
1579
|
-
const
|
|
2023
|
+
const modelMappingsByCoordinate = buildModelMappings(
|
|
2024
|
+
modelEntries,
|
|
2025
|
+
defaultNamespaceId,
|
|
2026
|
+
diagnostics,
|
|
2027
|
+
sourceId,
|
|
2028
|
+
);
|
|
2029
|
+
// Bare-name view for unqualified relation targets and polymorphism, where
|
|
2030
|
+
// resolution is by bare model name. When a bare name is shared across
|
|
2031
|
+
// namespaces this collapses to the last entry; qualified relation targets
|
|
2032
|
+
// and per-model lowering use the coordinate-keyed map above instead.
|
|
2033
|
+
const modelMappings = new Map<string, ModelNameMapping>();
|
|
2034
|
+
for (const mapping of modelMappingsByCoordinate.values()) {
|
|
2035
|
+
modelMappings.set(mapping.model.name, mapping);
|
|
2036
|
+
}
|
|
1580
2037
|
const modelNodes: ModelNode[] = [];
|
|
1581
2038
|
const fkRelationMetadata: FkRelationMetadata[] = [];
|
|
1582
2039
|
const backrelationCandidates: ModelBackrelationCandidate[] = [];
|
|
1583
2040
|
const modelResolvedFields = new Map<string, readonly ResolvedField[]>();
|
|
2041
|
+
// Cross-space relation nodes keyed by declaring model name — merged into
|
|
2042
|
+
// modelRelations after local back-relation matching so they bypass that step.
|
|
2043
|
+
const crossSpaceRelationsByModel = new Map<string, RelationNode[]>();
|
|
1584
2044
|
|
|
1585
|
-
for (const model of
|
|
1586
|
-
const
|
|
2045
|
+
for (const { model, namespaceId } of modelEntries) {
|
|
2046
|
+
const coordinate = modelCoordinateKey(namespaceId ?? defaultNamespaceId, model.name);
|
|
2047
|
+
const mapping = modelMappingsByCoordinate.get(coordinate);
|
|
1587
2048
|
if (!mapping) {
|
|
1588
2049
|
continue;
|
|
1589
2050
|
}
|
|
@@ -1591,11 +2052,13 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1591
2052
|
model,
|
|
1592
2053
|
mapping,
|
|
1593
2054
|
modelMappings,
|
|
2055
|
+
modelMappingsByCoordinate,
|
|
1594
2056
|
modelNames,
|
|
1595
2057
|
compositeTypeNames,
|
|
1596
2058
|
enumTypeDescriptors: allEnumTypeDescriptors,
|
|
1597
2059
|
namedTypeDescriptors: namedTypeResult.namedTypeDescriptors,
|
|
1598
2060
|
composedExtensions,
|
|
2061
|
+
composedExtensionContracts,
|
|
1599
2062
|
familyId: input.target.familyId,
|
|
1600
2063
|
targetId: input.target.targetId,
|
|
1601
2064
|
authoringContributions: input.authoringContributions,
|
|
@@ -1606,15 +2069,16 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1606
2069
|
diagnostics,
|
|
1607
2070
|
modelNamespaceIds,
|
|
1608
2071
|
});
|
|
1609
|
-
const resolvedNamespaceId = modelNamespaceIds.get(model.name);
|
|
1610
2072
|
modelNodes.push(
|
|
1611
|
-
|
|
1612
|
-
? { ...result.modelNode, namespaceId: resolvedNamespaceId }
|
|
1613
|
-
: result.modelNode,
|
|
2073
|
+
namespaceId !== undefined ? { ...result.modelNode, namespaceId } : result.modelNode,
|
|
1614
2074
|
);
|
|
1615
2075
|
fkRelationMetadata.push(...result.fkRelationMetadata);
|
|
1616
2076
|
backrelationCandidates.push(...result.backrelationCandidates);
|
|
1617
|
-
modelResolvedFields.set(
|
|
2077
|
+
modelResolvedFields.set(coordinate, result.resolvedFields);
|
|
2078
|
+
if (result.crossSpaceRelations.length > 0) {
|
|
2079
|
+
const existing = crossSpaceRelationsByModel.get(model.name) ?? [];
|
|
2080
|
+
crossSpaceRelationsByModel.set(model.name, [...existing, ...result.crossSpaceRelations]);
|
|
2081
|
+
}
|
|
1618
2082
|
}
|
|
1619
2083
|
|
|
1620
2084
|
const { modelRelations, fkRelationsByPair } = indexFkRelations({ fkRelationMetadata });
|
|
@@ -1626,12 +2090,44 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1626
2090
|
sourceId,
|
|
1627
2091
|
});
|
|
1628
2092
|
|
|
2093
|
+
// Merge cross-space relations into modelRelations after local back-relation matching.
|
|
2094
|
+
// Cross-space targets have no local back-relation candidates, so they bypass that step.
|
|
2095
|
+
for (const [modelName, relations] of crossSpaceRelationsByModel) {
|
|
2096
|
+
const existing = modelRelations.get(modelName);
|
|
2097
|
+
if (existing) {
|
|
2098
|
+
existing.push(...relations);
|
|
2099
|
+
} else {
|
|
2100
|
+
modelRelations.set(modelName, [...relations]);
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
|
|
1629
2104
|
const { discriminatorDeclarations, baseDeclarations } = collectPolymorphismDeclarations(
|
|
1630
2105
|
models,
|
|
1631
2106
|
sourceId,
|
|
1632
2107
|
diagnostics,
|
|
1633
2108
|
);
|
|
1634
2109
|
|
|
2110
|
+
// A variant with `@@base` but no own `@@map` is single-table inheritance:
|
|
2111
|
+
// it shares the base table. (`@@map` ⇒ multi-table inheritance.) This is the
|
|
2112
|
+
// authoritative STI/MTI signal — the variant's resolved table name is not,
|
|
2113
|
+
// because a no-`@@map` STI variant still gets a `lowerFirst(name)` default
|
|
2114
|
+
// table name that differs from the base before `resolvePolymorphism` rewrites
|
|
2115
|
+
// it onto the base table.
|
|
2116
|
+
const stiVariantNames = new Set<string>();
|
|
2117
|
+
for (const variantName of baseDeclarations.keys()) {
|
|
2118
|
+
const variantMapping = modelMappings.get(variantName);
|
|
2119
|
+
const hasExplicitMap =
|
|
2120
|
+
variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
|
|
2121
|
+
if (!hasExplicitMap) {
|
|
2122
|
+
stiVariantNames.add(variantName);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
const { modelNodes: mtiLinkedModelNodes, syntheticPkFieldsByVariant } =
|
|
2127
|
+
materializeMtiVariantStorageLinks(modelNodes, baseDeclarations, stiVariantNames);
|
|
2128
|
+
const { modelNodes: stiColumnModelNodes, stiBaseFieldsByBase } =
|
|
2129
|
+
materializeStiVariantStorageColumns(mtiLinkedModelNodes, baseDeclarations, stiVariantNames);
|
|
2130
|
+
|
|
1635
2131
|
const valueObjects = buildValueObjects({
|
|
1636
2132
|
compositeTypes,
|
|
1637
2133
|
enumTypeDescriptors: allEnumTypeDescriptors,
|
|
@@ -1667,7 +2163,7 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1667
2163
|
? { namespaceTypes: namespaceEnumStorageTypes }
|
|
1668
2164
|
: {}),
|
|
1669
2165
|
...ifDefined('createNamespace', input.createNamespace),
|
|
1670
|
-
models:
|
|
2166
|
+
models: stiColumnModelNodes.map((model) => ({
|
|
1671
2167
|
...model,
|
|
1672
2168
|
...(modelRelations.has(model.modelName)
|
|
1673
2169
|
? {
|
|
@@ -1679,15 +2175,17 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1679
2175
|
})),
|
|
1680
2176
|
});
|
|
1681
2177
|
|
|
2178
|
+
// Keyed by `(namespaceId, modelName)` coordinate so two models that share a
|
|
2179
|
+
// bare name across namespaces stay distinct through the patch/polymorphism
|
|
2180
|
+
// passes; only a genuine same-namespace duplicate is an error.
|
|
1682
2181
|
const modelsForPatch: Record<string, ContractModel> = {};
|
|
1683
|
-
for (const namespaceSlice of Object.
|
|
2182
|
+
for (const [namespaceId, namespaceSlice] of Object.entries(contract.domain.namespaces)) {
|
|
1684
2183
|
for (const [modelName, model] of Object.entries(namespaceSlice.models)) {
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
);
|
|
2184
|
+
const coordinate = modelCoordinateKey(namespaceId, modelName);
|
|
2185
|
+
if (Object.hasOwn(modelsForPatch, coordinate)) {
|
|
2186
|
+
throw new Error(`duplicate model "${namespaceId}.${modelName}" during PSL interpretation`);
|
|
1689
2187
|
}
|
|
1690
|
-
modelsForPatch[
|
|
2188
|
+
modelsForPatch[coordinate] = model;
|
|
1691
2189
|
}
|
|
1692
2190
|
}
|
|
1693
2191
|
let patchedModels = patchModelDomainFields(modelsForPatch, modelResolvedFields);
|
|
@@ -1700,7 +2198,9 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1700
2198
|
modelNames,
|
|
1701
2199
|
modelMappings,
|
|
1702
2200
|
modelNamespaceIds,
|
|
1703
|
-
input.target.
|
|
2201
|
+
input.target.defaultNamespaceId,
|
|
2202
|
+
syntheticPkFieldsByVariant,
|
|
2203
|
+
stiBaseFieldsByBase,
|
|
1704
2204
|
sourceId,
|
|
1705
2205
|
polyDiagnostics,
|
|
1706
2206
|
);
|
|
@@ -1730,13 +2230,13 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1730
2230
|
models: Object.fromEntries(
|
|
1731
2231
|
Object.entries(namespaceSlice.models).map(([modelName, model]) => [
|
|
1732
2232
|
modelName,
|
|
1733
|
-
patchedModels[modelName] ?? model,
|
|
2233
|
+
patchedModels[modelCoordinateKey(namespaceId, modelName)] ?? model,
|
|
1734
2234
|
]),
|
|
1735
2235
|
),
|
|
1736
2236
|
...(namespaceSlice.valueObjects !== undefined
|
|
1737
2237
|
? { valueObjects: namespaceSlice.valueObjects }
|
|
1738
2238
|
: {}),
|
|
1739
|
-
...(namespaceId ===
|
|
2239
|
+
...(namespaceId === input.target.defaultNamespaceId &&
|
|
1740
2240
|
Object.keys(valueObjects).length > 0
|
|
1741
2241
|
? { valueObjects }
|
|
1742
2242
|
: {}),
|