@prisma-next/sql-contract-psl 0.12.0-dev.6 → 0.12.0-dev.60
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 +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{interpreter-QE6eZZof.mjs → interpreter-1VmrYYbi.mjs} +373 -23
- package/dist/interpreter-1VmrYYbi.mjs.map +1 -0
- package/dist/provider.d.mts +5 -0
- package/dist/provider.d.mts.map +1 -1
- package/dist/provider.mjs +5 -3
- package/dist/provider.mjs.map +1 -1
- package/package.json +14 -14
- package/src/interpreter.ts +442 -20
- package/src/provider.ts +10 -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,
|
|
@@ -229,10 +234,6 @@ const UNSPECIFIED_PSL_NAMESPACE_NAME = '__unspecified__';
|
|
|
229
234
|
* slot empty (which means the late-bound default at the `StorageTable`
|
|
230
235
|
* layer; emitted JSON omits the field).
|
|
231
236
|
*/
|
|
232
|
-
function defaultSqlNamespaceIdForTarget(targetId: string): string {
|
|
233
|
-
return targetId === 'postgres' ? 'public' : UNBOUND_NAMESPACE_ID;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
237
|
function resolveNamespaceIdForSqlTarget(input: {
|
|
237
238
|
readonly bucketName: string;
|
|
238
239
|
readonly targetId: string;
|
|
@@ -605,6 +606,8 @@ interface BuildModelNodeResult {
|
|
|
605
606
|
readonly fkRelationMetadata: FkRelationMetadata[];
|
|
606
607
|
readonly backrelationCandidates: ModelBackrelationCandidate[];
|
|
607
608
|
readonly resolvedFields: readonly ResolvedField[];
|
|
609
|
+
/** Cross-contract-space relation nodes that bypass the local back-relation matching. */
|
|
610
|
+
readonly crossSpaceRelations: RelationNode[];
|
|
608
611
|
}
|
|
609
612
|
|
|
610
613
|
function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult {
|
|
@@ -647,6 +650,8 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
647
650
|
: undefined;
|
|
648
651
|
const hasInlinePrimaryKey = primaryKey !== undefined;
|
|
649
652
|
let blockPrimaryKeyDeclared = false;
|
|
653
|
+
let controlPolicyDeclared = false;
|
|
654
|
+
let controlPolicy: ControlPolicy | undefined;
|
|
650
655
|
|
|
651
656
|
const resultBackrelationCandidates: ModelBackrelationCandidate[] = [];
|
|
652
657
|
for (const field of model.fields) {
|
|
@@ -733,6 +738,27 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
733
738
|
if (modelAttribute.name === 'discriminator' || modelAttribute.name === 'base') {
|
|
734
739
|
continue;
|
|
735
740
|
}
|
|
741
|
+
if (modelAttribute.name === 'control') {
|
|
742
|
+
if (controlPolicyDeclared) {
|
|
743
|
+
diagnostics.push({
|
|
744
|
+
code: 'PSL_DUPLICATE_ATTRIBUTE',
|
|
745
|
+
message: `\`@@control\` declared more than once on model "${model.name}".`,
|
|
746
|
+
sourceId,
|
|
747
|
+
span: modelAttribute.span,
|
|
748
|
+
});
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
controlPolicyDeclared = true;
|
|
752
|
+
const parsed = parseControlPolicyAttribute({
|
|
753
|
+
attribute: modelAttribute,
|
|
754
|
+
sourceId,
|
|
755
|
+
diagnostics,
|
|
756
|
+
});
|
|
757
|
+
if (parsed !== undefined) {
|
|
758
|
+
controlPolicy = parsed;
|
|
759
|
+
}
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
736
762
|
const attributeLabel = `Model "${model.name}" @@${modelAttribute.name}`;
|
|
737
763
|
if (modelAttribute.name === 'id') {
|
|
738
764
|
if (blockPrimaryKeyDeclared) {
|
|
@@ -937,13 +963,162 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
937
963
|
}
|
|
938
964
|
|
|
939
965
|
const resultFkRelationMetadata: FkRelationMetadata[] = [];
|
|
966
|
+
const resultCrossSpaceRelations: RelationNode[] = [];
|
|
940
967
|
for (const relationAttribute of relationAttributes) {
|
|
968
|
+
const {
|
|
969
|
+
typeName: fieldTypeName,
|
|
970
|
+
typeNamespaceId: fieldTypeNamespaceId,
|
|
971
|
+
typeContractSpaceId: fieldTypeContractSpaceId,
|
|
972
|
+
} = relationAttribute.field;
|
|
973
|
+
|
|
941
974
|
if (relationAttribute.field.list) {
|
|
975
|
+
// F-list: cross-space list relations are explicitly unsupported (Option B does not
|
|
976
|
+
// navigate, so a list target makes no sense to carry). Emit a diagnostic instead of
|
|
977
|
+
// silently dropping the field — the author needs to know the field was ignored.
|
|
978
|
+
if (fieldTypeContractSpaceId !== undefined) {
|
|
979
|
+
diagnostics.push({
|
|
980
|
+
code: 'PSL_UNSUPPORTED_CROSS_SPACE_LIST',
|
|
981
|
+
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.`,
|
|
982
|
+
sourceId,
|
|
983
|
+
span: relationAttribute.field.span,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Cross-contract-space relation: the target model lives in a different contract space
|
|
990
|
+
// identified by `typeContractSpaceId` (e.g. `supabase:auth.User`).
|
|
991
|
+
if (fieldTypeContractSpaceId !== undefined) {
|
|
992
|
+
// Fail fast if the space is not in the composed extension packs (AC5 PSL half).
|
|
993
|
+
if (!input.composedExtensions.has(fieldTypeContractSpaceId)) {
|
|
994
|
+
diagnostics.push({
|
|
995
|
+
code: 'PSL_UNKNOWN_CONTRACT_SPACE',
|
|
996
|
+
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.`,
|
|
997
|
+
sourceId,
|
|
998
|
+
span: relationAttribute.field.span,
|
|
999
|
+
data: { space: fieldTypeContractSpaceId, suggestedPack: fieldTypeContractSpaceId },
|
|
1000
|
+
});
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const parsedRelation = parseRelationAttribute({
|
|
1005
|
+
attribute: relationAttribute.relation,
|
|
1006
|
+
modelName: model.name,
|
|
1007
|
+
fieldName: relationAttribute.field.name,
|
|
1008
|
+
sourceId,
|
|
1009
|
+
diagnostics,
|
|
1010
|
+
});
|
|
1011
|
+
if (!parsedRelation) {
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
if (!parsedRelation.fields || !parsedRelation.references) {
|
|
1015
|
+
diagnostics.push({
|
|
1016
|
+
code: 'PSL_INVALID_RELATION_ATTRIBUTE',
|
|
1017
|
+
message: `Relation field "${model.name}.${relationAttribute.field.name}" requires fields and references arguments`,
|
|
1018
|
+
sourceId,
|
|
1019
|
+
span: relationAttribute.relation.span,
|
|
1020
|
+
});
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const localColumns = mapFieldNamesToColumns({
|
|
1025
|
+
modelName: model.name,
|
|
1026
|
+
fieldNames: parsedRelation.fields,
|
|
1027
|
+
mapping,
|
|
1028
|
+
sourceId,
|
|
1029
|
+
diagnostics,
|
|
1030
|
+
span: relationAttribute.relation.span,
|
|
1031
|
+
entityLabel: `Relation field "${model.name}.${relationAttribute.field.name}"`,
|
|
1032
|
+
});
|
|
1033
|
+
if (!localColumns) {
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// For cross-space references the `references` list provides field names from the remote
|
|
1038
|
+
// model. Since the interpreter has no access to the extension contract, these field names
|
|
1039
|
+
// are treated as column names directly (matching the TS builder's cross-space path).
|
|
1040
|
+
const referencedColumns = parsedRelation.references;
|
|
1041
|
+
|
|
1042
|
+
if (localColumns.length !== referencedColumns.length) {
|
|
1043
|
+
diagnostics.push({
|
|
1044
|
+
code: 'PSL_INVALID_RELATION_ATTRIBUTE',
|
|
1045
|
+
message: `Relation field "${model.name}.${relationAttribute.field.name}" must provide the same number of fields and references`,
|
|
1046
|
+
sourceId,
|
|
1047
|
+
span: relationAttribute.relation.span,
|
|
1048
|
+
});
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const onDelete = parsedRelation.onDelete
|
|
1053
|
+
? normalizeReferentialAction({
|
|
1054
|
+
modelName: model.name,
|
|
1055
|
+
fieldName: relationAttribute.field.name,
|
|
1056
|
+
actionName: 'onDelete',
|
|
1057
|
+
actionToken: parsedRelation.onDelete,
|
|
1058
|
+
sourceId,
|
|
1059
|
+
span: relationAttribute.field.span,
|
|
1060
|
+
diagnostics,
|
|
1061
|
+
})
|
|
1062
|
+
: undefined;
|
|
1063
|
+
const onUpdate = parsedRelation.onUpdate
|
|
1064
|
+
? normalizeReferentialAction({
|
|
1065
|
+
modelName: model.name,
|
|
1066
|
+
fieldName: relationAttribute.field.name,
|
|
1067
|
+
actionName: 'onUpdate',
|
|
1068
|
+
actionToken: parsedRelation.onUpdate,
|
|
1069
|
+
sourceId,
|
|
1070
|
+
span: relationAttribute.field.span,
|
|
1071
|
+
diagnostics,
|
|
1072
|
+
})
|
|
1073
|
+
: undefined;
|
|
1074
|
+
|
|
1075
|
+
// Target namespace: use the colon-prefix namespace qualifier, or `__unbound__` when the
|
|
1076
|
+
// no-namespace form is used (e.g. `supabase:User` → AC3).
|
|
1077
|
+
const crossTargetNamespaceId = fieldTypeNamespaceId ?? '__unbound__';
|
|
1078
|
+
|
|
1079
|
+
// Target table name: the interpreter cannot resolve `User → users` because it has no
|
|
1080
|
+
// access to the extension contract (only a Set<string> of space names is available).
|
|
1081
|
+
// Use `fieldTypeName.toLowerCase()` as a symbolic fallback — the same convention the TS
|
|
1082
|
+
// builder uses (`targetModelName.toLowerCase()`) when `targetTableName` is absent.
|
|
1083
|
+
// Physical table resolution against the extension contract is deferred to the aggregate
|
|
1084
|
+
// stage (M3), which has access to the full extension contract.
|
|
1085
|
+
const crossTargetTableName = fieldTypeName.toLowerCase();
|
|
1086
|
+
|
|
1087
|
+
foreignKeyNodes.push({
|
|
1088
|
+
columns: localColumns,
|
|
1089
|
+
references: {
|
|
1090
|
+
model: fieldTypeName,
|
|
1091
|
+
table: crossTargetTableName,
|
|
1092
|
+
columns: referencedColumns,
|
|
1093
|
+
namespaceId: crossTargetNamespaceId,
|
|
1094
|
+
spaceId: fieldTypeContractSpaceId,
|
|
1095
|
+
},
|
|
1096
|
+
...ifDefined('name', parsedRelation.constraintName),
|
|
1097
|
+
...ifDefined('onDelete', onDelete),
|
|
1098
|
+
...ifDefined('onUpdate', onUpdate),
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
// Build the cross-space RelationNode directly (no local back-relation candidate).
|
|
1102
|
+
// `buildSqlContractFromDefinition` recognises `spaceId` on a RelationNode and routes it
|
|
1103
|
+
// through the cross-space domain-relation path (produces a non-navigable CrossReference).
|
|
1104
|
+
resultCrossSpaceRelations.push({
|
|
1105
|
+
fieldName: relationAttribute.field.name,
|
|
1106
|
+
toModel: fieldTypeName,
|
|
1107
|
+
toTable: crossTargetTableName,
|
|
1108
|
+
cardinality: 'N:1',
|
|
1109
|
+
spaceId: fieldTypeContractSpaceId,
|
|
1110
|
+
namespaceId: crossTargetNamespaceId,
|
|
1111
|
+
on: {
|
|
1112
|
+
parentTable: tableName,
|
|
1113
|
+
parentColumns: localColumns,
|
|
1114
|
+
childTable: crossTargetTableName,
|
|
1115
|
+
childColumns: referencedColumns,
|
|
1116
|
+
},
|
|
1117
|
+
});
|
|
1118
|
+
|
|
942
1119
|
continue;
|
|
943
1120
|
}
|
|
944
1121
|
|
|
945
|
-
const { typeName: fieldTypeName, typeNamespaceId: fieldTypeNamespaceId } =
|
|
946
|
-
relationAttribute.field;
|
|
947
1122
|
const qualifiedTypeName = fieldTypeNamespaceId
|
|
948
1123
|
? `${fieldTypeNamespaceId}.${fieldTypeName}`
|
|
949
1124
|
: fieldTypeName;
|
|
@@ -1103,8 +1278,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult
|
|
|
1103
1278
|
...(uniqueConstraints.length > 0 ? { uniques: uniqueConstraints } : {}),
|
|
1104
1279
|
...(indexNodes.length > 0 ? { indexes: indexNodes } : {}),
|
|
1105
1280
|
...(foreignKeyNodes.length > 0 ? { foreignKeys: foreignKeyNodes } : {}),
|
|
1281
|
+
...ifDefined('control', controlPolicy),
|
|
1106
1282
|
},
|
|
1107
1283
|
fkRelationMetadata: resultFkRelationMetadata,
|
|
1284
|
+
crossSpaceRelations: resultCrossSpaceRelations,
|
|
1108
1285
|
backrelationCandidates: resultBackrelationCandidates,
|
|
1109
1286
|
resolvedFields,
|
|
1110
1287
|
};
|
|
@@ -1310,12 +1487,27 @@ function resolvePolymorphism(
|
|
|
1310
1487
|
modelNames: Set<string>,
|
|
1311
1488
|
modelMappings: ReadonlyMap<string, ModelNameMapping>,
|
|
1312
1489
|
modelNamespaceIds: ReadonlyMap<string, string>,
|
|
1313
|
-
|
|
1490
|
+
defaultNamespaceId: string,
|
|
1491
|
+
syntheticPkFieldsByVariant: ReadonlyMap<string, readonly string[]>,
|
|
1492
|
+
stiBaseFieldsByBase: ReadonlyMap<string, readonly string[]>,
|
|
1314
1493
|
sourceId: string,
|
|
1315
1494
|
diagnostics: ContractSourceDiagnostic[],
|
|
1316
1495
|
): Record<string, ContractModel> {
|
|
1317
1496
|
let patched = models;
|
|
1318
1497
|
|
|
1498
|
+
// STI variant columns were materialised onto the base storage table so the
|
|
1499
|
+
// variants' `storage.fields` resolve. They are storage-only on the base — the
|
|
1500
|
+
// domain field belongs to the variant — so strip them from the base model's
|
|
1501
|
+
// domain + storage field maps (the table column, built upstream, stays).
|
|
1502
|
+
for (const [baseName, fieldNames] of stiBaseFieldsByBase) {
|
|
1503
|
+
const baseModel = patched[baseName];
|
|
1504
|
+
if (!baseModel || fieldNames.length === 0) continue;
|
|
1505
|
+
patched = {
|
|
1506
|
+
...patched,
|
|
1507
|
+
[baseName]: stripStorageOnlyDomainFields(baseModel, fieldNames),
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1319
1511
|
for (const [modelName, decl] of discriminatorDeclarations) {
|
|
1320
1512
|
if (baseDeclarations.has(modelName)) {
|
|
1321
1513
|
diagnostics.push({
|
|
@@ -1410,22 +1602,211 @@ function resolvePolymorphism(
|
|
|
1410
1602
|
variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
|
|
1411
1603
|
const resolvedTable = hasExplicitMap ? variantMapping?.tableName : baseMapping?.tableName;
|
|
1412
1604
|
|
|
1605
|
+
const patchedVariant: ContractModel = {
|
|
1606
|
+
...variantModel,
|
|
1607
|
+
base: crossRef(
|
|
1608
|
+
baseDecl.baseName,
|
|
1609
|
+
modelNamespaceIds.get(baseDecl.baseName) ?? defaultNamespaceId,
|
|
1610
|
+
),
|
|
1611
|
+
...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}),
|
|
1612
|
+
};
|
|
1613
|
+
|
|
1413
1614
|
patched = {
|
|
1414
1615
|
...patched,
|
|
1415
|
-
[variantName]:
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
modelNamespaceIds.get(baseDecl.baseName) ?? defaultSqlNamespaceIdForTarget(targetId),
|
|
1420
|
-
),
|
|
1421
|
-
...(resolvedTable ? { storage: { ...variantModel.storage, table: resolvedTable } } : {}),
|
|
1422
|
-
},
|
|
1616
|
+
[variantName]: stripStorageOnlyDomainFields(
|
|
1617
|
+
patchedVariant,
|
|
1618
|
+
syntheticPkFieldsByVariant.get(variantName) ?? [],
|
|
1619
|
+
),
|
|
1423
1620
|
};
|
|
1424
1621
|
}
|
|
1425
1622
|
|
|
1426
1623
|
return patched;
|
|
1427
1624
|
}
|
|
1428
1625
|
|
|
1626
|
+
/**
|
|
1627
|
+
* Multi-table-inheritance variants (`@@base` + their own `@@map`) live in a
|
|
1628
|
+
* separate table from their base. The ORM joins that table to the base on the
|
|
1629
|
+
* shared primary key (`base.id = variant.id`), so the variant storage table
|
|
1630
|
+
* must carry the base PK column even though the variant domain model declares
|
|
1631
|
+
* only its own fields. This enriches each MTI variant's `ModelNode` with that
|
|
1632
|
+
* link column, a primary key on it, and a FK back to the base table.
|
|
1633
|
+
*
|
|
1634
|
+
* The link column is reported back per variant in `syntheticPkFieldsByVariant`
|
|
1635
|
+
* so the domain-model patch can drop it again — keeping the variant's domain
|
|
1636
|
+
* surface thin (its create/read inputs don't gain a redundant `id`) while the
|
|
1637
|
+
* storage table stays joinable. Single-table-inheritance variants (no own
|
|
1638
|
+
* table) are left untouched.
|
|
1639
|
+
*/
|
|
1640
|
+
function materializeMtiVariantStorageLinks(
|
|
1641
|
+
modelNodes: readonly ModelNode[],
|
|
1642
|
+
baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
|
|
1643
|
+
stiVariantNames: ReadonlySet<string>,
|
|
1644
|
+
): { modelNodes: ModelNode[]; syntheticPkFieldsByVariant: Map<string, readonly string[]> } {
|
|
1645
|
+
const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
|
|
1646
|
+
const syntheticPkFieldsByVariant = new Map<string, readonly string[]>();
|
|
1647
|
+
|
|
1648
|
+
const enriched = modelNodes.map((node): ModelNode => {
|
|
1649
|
+
const baseDecl = baseDeclarations.get(node.modelName);
|
|
1650
|
+
if (!baseDecl) return node;
|
|
1651
|
+
const baseNode = nodeByModel.get(baseDecl.baseName);
|
|
1652
|
+
if (!baseNode) return node;
|
|
1653
|
+
// Single-table inheritance (no own `@@map`) shares the base table; it gets
|
|
1654
|
+
// its columns materialised onto the base instead (see
|
|
1655
|
+
// {@link materializeStiVariantStorageColumns}), never a link column.
|
|
1656
|
+
if (stiVariantNames.has(node.modelName)) return node;
|
|
1657
|
+
const basePrimaryKey = baseNode.id;
|
|
1658
|
+
if (!basePrimaryKey || basePrimaryKey.columns.length === 0) return node;
|
|
1659
|
+
|
|
1660
|
+
const existingColumns = new Set(node.fields.map((field) => field.columnName));
|
|
1661
|
+
const linkFields: FieldNode[] = [];
|
|
1662
|
+
for (const pkColumn of basePrimaryKey.columns) {
|
|
1663
|
+
if (existingColumns.has(pkColumn)) continue;
|
|
1664
|
+
const baseField = baseNode.fields.find(
|
|
1665
|
+
(field): field is FieldNode => 'descriptor' in field && field.columnName === pkColumn,
|
|
1666
|
+
);
|
|
1667
|
+
if (!baseField) continue;
|
|
1668
|
+
linkFields.push({
|
|
1669
|
+
fieldName: baseField.fieldName,
|
|
1670
|
+
columnName: pkColumn,
|
|
1671
|
+
descriptor: baseField.descriptor,
|
|
1672
|
+
nullable: false,
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
if (linkFields.length === 0) return node;
|
|
1676
|
+
|
|
1677
|
+
syntheticPkFieldsByVariant.set(
|
|
1678
|
+
node.modelName,
|
|
1679
|
+
linkFields.map((field) => field.fieldName),
|
|
1680
|
+
);
|
|
1681
|
+
|
|
1682
|
+
const foreignKey: ForeignKeyNode = {
|
|
1683
|
+
columns: basePrimaryKey.columns,
|
|
1684
|
+
references: {
|
|
1685
|
+
model: baseNode.modelName,
|
|
1686
|
+
table: baseNode.tableName,
|
|
1687
|
+
columns: basePrimaryKey.columns,
|
|
1688
|
+
...ifDefined('namespaceId', baseNode.namespaceId),
|
|
1689
|
+
},
|
|
1690
|
+
constraint: true,
|
|
1691
|
+
// The link columns are the variant's own primary key, which already
|
|
1692
|
+
// carries a unique index — a separate FK backing index would be redundant.
|
|
1693
|
+
index: false,
|
|
1694
|
+
// Deleting a base row must delete its variant extension row — classic
|
|
1695
|
+
// multi-table-inheritance semantics.
|
|
1696
|
+
onDelete: 'cascade',
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
return {
|
|
1700
|
+
...node,
|
|
1701
|
+
fields: [...linkFields, ...node.fields],
|
|
1702
|
+
id: { columns: basePrimaryKey.columns },
|
|
1703
|
+
foreignKeys: [...(node.foreignKeys ?? []), foreignKey],
|
|
1704
|
+
};
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
return { modelNodes: enriched, syntheticPkFieldsByVariant };
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* Single-table-inheritance variants (`@@base` with no own `@@map`) share the
|
|
1712
|
+
* base table: `resolvePolymorphism` points the variant's `storage.table` at the
|
|
1713
|
+
* base, and the ORM reads variant-declared fields straight off the base table.
|
|
1714
|
+
* For that to validate and round-trip, the base storage table must physically
|
|
1715
|
+
* carry every STI variant's declared columns. This enriches the base
|
|
1716
|
+
* `ModelNode` with those columns.
|
|
1717
|
+
*
|
|
1718
|
+
* The materialised columns are always nullable in storage: the base table hosts
|
|
1719
|
+
* every variant's rows, so a column a variant declares as required is still
|
|
1720
|
+
* NULL on sibling-variant rows. The variant's domain field keeps its declared
|
|
1721
|
+
* nullability — required-in-domain / nullable-in-storage is the intended STI
|
|
1722
|
+
* shape.
|
|
1723
|
+
*
|
|
1724
|
+
* Collisions (two variants declaring the same column, or a variant column name
|
|
1725
|
+
* clashing with a base column) are resolved skip-if-exists here, mirroring the
|
|
1726
|
+
* MTI link guard; surfacing them as diagnostics is tracked separately
|
|
1727
|
+
* (TML-2827).
|
|
1728
|
+
*/
|
|
1729
|
+
function materializeStiVariantStorageColumns(
|
|
1730
|
+
modelNodes: readonly ModelNode[],
|
|
1731
|
+
baseDeclarations: ReadonlyMap<string, BaseDeclaration>,
|
|
1732
|
+
stiVariantNames: ReadonlySet<string>,
|
|
1733
|
+
): { modelNodes: ModelNode[]; stiBaseFieldsByBase: Map<string, readonly string[]> } {
|
|
1734
|
+
if (stiVariantNames.size === 0) {
|
|
1735
|
+
return { modelNodes: [...modelNodes], stiBaseFieldsByBase: new Map() };
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
const nodeByModel = new Map(modelNodes.map((node) => [node.modelName, node]));
|
|
1739
|
+
type StiColumn = ModelNode['fields'][number];
|
|
1740
|
+
const stiColumnsByBase = new Map<string, StiColumn[]>();
|
|
1741
|
+
|
|
1742
|
+
for (const variantName of stiVariantNames) {
|
|
1743
|
+
const variantNode = nodeByModel.get(variantName);
|
|
1744
|
+
const baseDecl = baseDeclarations.get(variantName);
|
|
1745
|
+
if (!variantNode || !baseDecl) continue;
|
|
1746
|
+
const baseNode = nodeByModel.get(baseDecl.baseName);
|
|
1747
|
+
if (!baseNode) continue;
|
|
1748
|
+
|
|
1749
|
+
const baseColumns = new Set(baseNode.fields.map((field) => field.columnName));
|
|
1750
|
+
const claimed = stiColumnsByBase.get(baseDecl.baseName) ?? [];
|
|
1751
|
+
const claimedColumns = new Set(claimed.map((field) => field.columnName));
|
|
1752
|
+
|
|
1753
|
+
for (const field of variantNode.fields) {
|
|
1754
|
+
if (baseColumns.has(field.columnName) || claimedColumns.has(field.columnName)) {
|
|
1755
|
+
continue;
|
|
1756
|
+
}
|
|
1757
|
+
claimedColumns.add(field.columnName);
|
|
1758
|
+
claimed.push({ ...field, nullable: true });
|
|
1759
|
+
}
|
|
1760
|
+
stiColumnsByBase.set(baseDecl.baseName, claimed);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// The materialised columns exist on the base STORAGE table so the variants'
|
|
1764
|
+
// `storage.fields` resolve, but they are NOT base DOMAIN fields — `severity`
|
|
1765
|
+
// belongs to `Bug`, not to `Task`. Report the materialised field names per
|
|
1766
|
+
// base so the domain patch can strip them from the base model (the table
|
|
1767
|
+
// column stays); this is the STI analogue of `syntheticPkFieldsByVariant`.
|
|
1768
|
+
const stiBaseFieldsByBase = new Map<string, readonly string[]>();
|
|
1769
|
+
for (const [baseName, columns] of stiColumnsByBase) {
|
|
1770
|
+
stiBaseFieldsByBase.set(
|
|
1771
|
+
baseName,
|
|
1772
|
+
columns.map((field) => field.fieldName),
|
|
1773
|
+
);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
const enriched = modelNodes.map((node): ModelNode => {
|
|
1777
|
+
// STI variant: contributes a domain model but no storage table of its own.
|
|
1778
|
+
if (stiVariantNames.has(node.modelName)) {
|
|
1779
|
+
return { ...node, sharesBaseTable: true };
|
|
1780
|
+
}
|
|
1781
|
+
const stiColumns = stiColumnsByBase.get(node.modelName);
|
|
1782
|
+
if (!stiColumns || stiColumns.length === 0) return node;
|
|
1783
|
+
return { ...node, fields: [...node.fields, ...stiColumns] };
|
|
1784
|
+
});
|
|
1785
|
+
|
|
1786
|
+
return { modelNodes: enriched, stiBaseFieldsByBase };
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Drop the storage-only link fields (added by
|
|
1791
|
+
* {@link materializeMtiVariantStorageLinks}) from a variant's domain model, so
|
|
1792
|
+
* the domain surface stays thin while the storage table keeps the link column.
|
|
1793
|
+
*/
|
|
1794
|
+
function stripStorageOnlyDomainFields(
|
|
1795
|
+
model: ContractModel,
|
|
1796
|
+
fieldNames: readonly string[],
|
|
1797
|
+
): ContractModel {
|
|
1798
|
+
if (fieldNames.length === 0) return model;
|
|
1799
|
+
const fields = { ...model.fields };
|
|
1800
|
+
for (const name of fieldNames) delete fields[name];
|
|
1801
|
+
const storage = blindCast<
|
|
1802
|
+
SqlModelStorage,
|
|
1803
|
+
'SQL interpreter domain models always carry SqlModelStorage'
|
|
1804
|
+
>(model.storage);
|
|
1805
|
+
const storageFields = { ...storage.fields };
|
|
1806
|
+
for (const name of fieldNames) delete storageFields[name];
|
|
1807
|
+
return { ...model, fields, storage: { ...storage, fields: storageFields } };
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1429
1810
|
export function interpretPslDocumentToSqlContract(
|
|
1430
1811
|
input: InterpretPslDocumentToSqlContractInput,
|
|
1431
1812
|
): Result<Contract, ContractSourceDiagnostics> {
|
|
@@ -1581,6 +1962,9 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1581
1962
|
const fkRelationMetadata: FkRelationMetadata[] = [];
|
|
1582
1963
|
const backrelationCandidates: ModelBackrelationCandidate[] = [];
|
|
1583
1964
|
const modelResolvedFields = new Map<string, readonly ResolvedField[]>();
|
|
1965
|
+
// Cross-space relation nodes keyed by declaring model name — merged into
|
|
1966
|
+
// modelRelations after local back-relation matching so they bypass that step.
|
|
1967
|
+
const crossSpaceRelationsByModel = new Map<string, RelationNode[]>();
|
|
1584
1968
|
|
|
1585
1969
|
for (const model of models) {
|
|
1586
1970
|
const mapping = modelMappings.get(model.name);
|
|
@@ -1615,6 +1999,10 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1615
1999
|
fkRelationMetadata.push(...result.fkRelationMetadata);
|
|
1616
2000
|
backrelationCandidates.push(...result.backrelationCandidates);
|
|
1617
2001
|
modelResolvedFields.set(model.name, result.resolvedFields);
|
|
2002
|
+
if (result.crossSpaceRelations.length > 0) {
|
|
2003
|
+
const existing = crossSpaceRelationsByModel.get(model.name) ?? [];
|
|
2004
|
+
crossSpaceRelationsByModel.set(model.name, [...existing, ...result.crossSpaceRelations]);
|
|
2005
|
+
}
|
|
1618
2006
|
}
|
|
1619
2007
|
|
|
1620
2008
|
const { modelRelations, fkRelationsByPair } = indexFkRelations({ fkRelationMetadata });
|
|
@@ -1626,12 +2014,44 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1626
2014
|
sourceId,
|
|
1627
2015
|
});
|
|
1628
2016
|
|
|
2017
|
+
// Merge cross-space relations into modelRelations after local back-relation matching.
|
|
2018
|
+
// Cross-space targets have no local back-relation candidates, so they bypass that step.
|
|
2019
|
+
for (const [modelName, relations] of crossSpaceRelationsByModel) {
|
|
2020
|
+
const existing = modelRelations.get(modelName);
|
|
2021
|
+
if (existing) {
|
|
2022
|
+
existing.push(...relations);
|
|
2023
|
+
} else {
|
|
2024
|
+
modelRelations.set(modelName, [...relations]);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
1629
2028
|
const { discriminatorDeclarations, baseDeclarations } = collectPolymorphismDeclarations(
|
|
1630
2029
|
models,
|
|
1631
2030
|
sourceId,
|
|
1632
2031
|
diagnostics,
|
|
1633
2032
|
);
|
|
1634
2033
|
|
|
2034
|
+
// A variant with `@@base` but no own `@@map` is single-table inheritance:
|
|
2035
|
+
// it shares the base table. (`@@map` ⇒ multi-table inheritance.) This is the
|
|
2036
|
+
// authoritative STI/MTI signal — the variant's resolved table name is not,
|
|
2037
|
+
// because a no-`@@map` STI variant still gets a `lowerFirst(name)` default
|
|
2038
|
+
// table name that differs from the base before `resolvePolymorphism` rewrites
|
|
2039
|
+
// it onto the base table.
|
|
2040
|
+
const stiVariantNames = new Set<string>();
|
|
2041
|
+
for (const variantName of baseDeclarations.keys()) {
|
|
2042
|
+
const variantMapping = modelMappings.get(variantName);
|
|
2043
|
+
const hasExplicitMap =
|
|
2044
|
+
variantMapping?.model.attributes.some((attr) => attr.name === 'map') ?? false;
|
|
2045
|
+
if (!hasExplicitMap) {
|
|
2046
|
+
stiVariantNames.add(variantName);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
const { modelNodes: mtiLinkedModelNodes, syntheticPkFieldsByVariant } =
|
|
2051
|
+
materializeMtiVariantStorageLinks(modelNodes, baseDeclarations, stiVariantNames);
|
|
2052
|
+
const { modelNodes: stiColumnModelNodes, stiBaseFieldsByBase } =
|
|
2053
|
+
materializeStiVariantStorageColumns(mtiLinkedModelNodes, baseDeclarations, stiVariantNames);
|
|
2054
|
+
|
|
1635
2055
|
const valueObjects = buildValueObjects({
|
|
1636
2056
|
compositeTypes,
|
|
1637
2057
|
enumTypeDescriptors: allEnumTypeDescriptors,
|
|
@@ -1667,7 +2087,7 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1667
2087
|
? { namespaceTypes: namespaceEnumStorageTypes }
|
|
1668
2088
|
: {}),
|
|
1669
2089
|
...ifDefined('createNamespace', input.createNamespace),
|
|
1670
|
-
models:
|
|
2090
|
+
models: stiColumnModelNodes.map((model) => ({
|
|
1671
2091
|
...model,
|
|
1672
2092
|
...(modelRelations.has(model.modelName)
|
|
1673
2093
|
? {
|
|
@@ -1687,7 +2107,7 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1687
2107
|
`duplicate model name "${modelName}" across domain namespaces during PSL interpretation`,
|
|
1688
2108
|
);
|
|
1689
2109
|
}
|
|
1690
|
-
modelsForPatch[modelName] = model
|
|
2110
|
+
modelsForPatch[modelName] = model;
|
|
1691
2111
|
}
|
|
1692
2112
|
}
|
|
1693
2113
|
let patchedModels = patchModelDomainFields(modelsForPatch, modelResolvedFields);
|
|
@@ -1700,7 +2120,9 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1700
2120
|
modelNames,
|
|
1701
2121
|
modelMappings,
|
|
1702
2122
|
modelNamespaceIds,
|
|
1703
|
-
input.target.
|
|
2123
|
+
input.target.defaultNamespaceId,
|
|
2124
|
+
syntheticPkFieldsByVariant,
|
|
2125
|
+
stiBaseFieldsByBase,
|
|
1704
2126
|
sourceId,
|
|
1705
2127
|
polyDiagnostics,
|
|
1706
2128
|
);
|
|
@@ -1736,7 +2158,7 @@ export function interpretPslDocumentToSqlContract(
|
|
|
1736
2158
|
...(namespaceSlice.valueObjects !== undefined
|
|
1737
2159
|
? { valueObjects: namespaceSlice.valueObjects }
|
|
1738
2160
|
: {}),
|
|
1739
|
-
...(namespaceId ===
|
|
2161
|
+
...(namespaceId === input.target.defaultNamespaceId &&
|
|
1740
2162
|
Object.keys(valueObjects).length > 0
|
|
1741
2163
|
? { valueObjects }
|
|
1742
2164
|
: {}),
|
package/src/provider.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import type { ContractConfig } from '@prisma-next/config/config-types';
|
|
3
|
+
import { applySpecifierDefaultControlPolicy } from '@prisma-next/contract/apply-specifier-default-control-policy';
|
|
4
|
+
import type { ControlPolicy } from '@prisma-next/contract/types';
|
|
3
5
|
import type { CodecLookup } from '@prisma-next/framework-components/codec';
|
|
4
6
|
import type { ExtensionPackRef, TargetPackRef } from '@prisma-next/framework-components/components';
|
|
7
|
+
import type { Namespace } from '@prisma-next/framework-components/ir';
|
|
5
8
|
import { parsePslDocument } from '@prisma-next/psl-parser';
|
|
9
|
+
import type { SqlNamespaceTablesInput } from '@prisma-next/sql-contract/types';
|
|
6
10
|
import { ifDefined } from '@prisma-next/utils/defined';
|
|
7
11
|
import { notOk, ok } from '@prisma-next/utils/result';
|
|
8
12
|
import { basename, extname } from 'pathe';
|
|
@@ -13,6 +17,8 @@ export interface PrismaContractOptions {
|
|
|
13
17
|
readonly output?: string;
|
|
14
18
|
readonly target: TargetPackRef<'sql', string>;
|
|
15
19
|
readonly composedExtensionPackRefs?: readonly ExtensionPackRef<'sql', string>[];
|
|
20
|
+
readonly createNamespace?: (input: SqlNamespaceTablesInput) => Namespace;
|
|
21
|
+
readonly defaultControlPolicy?: ControlPolicy;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/**
|
|
@@ -106,12 +112,15 @@ export function prismaContract(schemaPath: string, options: PrismaContractOption
|
|
|
106
112
|
: undefined,
|
|
107
113
|
),
|
|
108
114
|
controlMutationDefaults: context.controlMutationDefaults,
|
|
115
|
+
...ifDefined('createNamespace', options.createNamespace),
|
|
109
116
|
});
|
|
110
117
|
if (!interpreted.ok) {
|
|
111
118
|
return interpreted;
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
return ok(
|
|
121
|
+
return ok(
|
|
122
|
+
applySpecifierDefaultControlPolicy(interpreted.value, options.defaultControlPolicy),
|
|
123
|
+
);
|
|
115
124
|
},
|
|
116
125
|
},
|
|
117
126
|
output: options.output ?? defaultOutputFromSchemaPath(schemaPath),
|