diff --git a/packages/2-sql/1-core/contract/src/resolve-storage-table.ts b/packages/2-sql/1-core/contract/src/resolve-storage-table.ts index eeb719aeb9..49a3520392 100644 --- a/packages/2-sql/1-core/contract/src/resolve-storage-table.ts +++ b/packages/2-sql/1-core/contract/src/resolve-storage-table.ts @@ -21,21 +21,41 @@ function tableInNamespace( } /** - * Resolve a bare storage table name to its namespace coordinate and table IR by - * scanning the contract's namespaces. For the single-namespace contracts in - * scope the scan is exact; cross-namespace bare-name collisions are selected - * explicitly (TML-2550). + * Resolve a bare storage table name to its namespace coordinate and table IR. + * + * When `namespaceId` is supplied, the table is resolved strictly within that + * namespace (no scan). When omitted, a bare name unique across namespaces + * resolves to its sole namespace; a bare name declared in more than one + * namespace throws a fail-fast diagnostic naming the candidate namespaces + * rather than silently selecting the first match. */ export function resolveStorageTable( storage: SqlStorage, tableName: string, + namespaceId?: string, ): ResolvedStorageTable | undefined { - for (const namespaceId of Object.keys(storage.namespaces)) { + if (namespaceId !== undefined) { const table = tableInNamespace(storage.namespaces[namespaceId], tableName); + return table === undefined ? undefined : { namespaceId, table }; + } + + const matches: ResolvedStorageTable[] = []; + for (const candidateNamespaceId of Object.keys(storage.namespaces)) { + const table = tableInNamespace(storage.namespaces[candidateNamespaceId], tableName); if (table !== undefined) { - return { namespaceId, table }; + matches.push({ namespaceId: candidateNamespaceId, table }); } } - return undefined; + if (matches.length > 1) { + const candidates = matches + .map((match) => match.namespaceId) + .sort() + .join(', '); + throw new Error( + `Storage table "${tableName}" is ambiguous across namespaces [${candidates}]; qualify it with a namespace coordinate.`, + ); + } + + return matches[0]; } diff --git a/packages/2-sql/1-core/contract/test/resolve-storage-table.test.ts b/packages/2-sql/1-core/contract/test/resolve-storage-table.test.ts index a1c2a743d7..a86336b763 100644 --- a/packages/2-sql/1-core/contract/test/resolve-storage-table.test.ts +++ b/packages/2-sql/1-core/contract/test/resolve-storage-table.test.ts @@ -17,6 +17,36 @@ function tableNamed(_name: string): StorageTable { }); } +function tableWithColumn(columnName: string): StorageTable { + return new StorageTable({ + columns: { + id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false }, + [columnName]: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }); +} + +function twoNamespaceSameTableName(): { + storage: SqlStorage; + publicUsers: StorageTable; + authUsers: StorageTable; +} { + const publicUsers = tableWithColumn('email_addr'); + const authUsers = tableWithColumn('token_col'); + const storage = new SqlStorage({ + storageHash: 'sha256:test', + namespaces: { + public: buildSqlNamespace({ id: 'public', entries: { table: { users: publicUsers } } }), + auth: buildSqlNamespace({ id: 'auth', entries: { table: { users: authUsers } } }), + }, + }); + return { storage, publicUsers, authUsers }; +} + describe('resolveStorageTable', () => { it('finds a table in whichever namespace declares it', () => { const authOnly = tableNamed('auth-only'); @@ -60,4 +90,31 @@ describe('resolveStorageTable', () => { expect(resolveStorageTable(storage, 'missing')).toBeUndefined(); }); + + it('resolves a same-bare-name table strictly within the given namespace', () => { + const { storage, publicUsers, authUsers } = twoNamespaceSameTableName(); + + expect(resolveStorageTable(storage, 'users', 'public')).toEqual({ + namespaceId: 'public', + table: publicUsers, + }); + expect(resolveStorageTable(storage, 'users', 'auth')).toEqual({ + namespaceId: 'auth', + table: authUsers, + }); + }); + + it('throws naming the candidate namespaces for an ambiguous bare table name', () => { + const { storage } = twoNamespaceSameTableName(); + + expect(() => resolveStorageTable(storage, 'users')).toThrow(/ambiguous/i); + expect(() => resolveStorageTable(storage, 'users')).toThrow(/auth/); + expect(() => resolveStorageTable(storage, 'users')).toThrow(/public/); + }); + + it('returns undefined for an unknown table within a given namespace', () => { + const { storage } = twoNamespaceSameTableName(); + + expect(resolveStorageTable(storage, 'missing', 'public')).toBeUndefined(); + }); }); diff --git a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts index 837ed59512..e13f36ed7b 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/interpreter.ts @@ -82,6 +82,8 @@ import { buildModelMappings, collectResolvedFields, type ModelNameMapping, + type ModelNamespaceEntry, + modelCoordinateKey, type ResolvedField, } from './psl-field-resolution'; import { @@ -594,6 +596,12 @@ interface BuildModelNodeInput { readonly model: PslModel; readonly mapping: ModelNameMapping; readonly modelMappings: ReadonlyMap; + /** + * Model mappings keyed by `(namespaceId, modelName)` coordinate. Used to + * resolve a namespace-qualified relation target (`auth.User`) to the exact + * model even when the bare name is shared across namespaces. + */ + readonly modelMappingsByCoordinate: ReadonlyMap; readonly modelNames: Set; readonly compositeTypeNames: ReadonlySet; readonly enumTypeDescriptors: Map; @@ -1166,19 +1174,23 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult continue; } - if (fieldTypeNamespaceId !== undefined) { - const resolvedTargetNamespaceId = input.modelNamespaceIds.get(fieldTypeName); - const normalizedQualifier = - fieldTypeNamespaceId === 'unbound' ? '__unbound__' : fieldTypeNamespaceId; - if (resolvedTargetNamespaceId !== normalizedQualifier) { - diagnostics.push({ - code: 'PSL_INVALID_RELATION_TARGET', - message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${qualifiedTypeName}"`, - sourceId, - span: relationAttribute.field.span, - }); - continue; - } + const normalizedQualifier = + fieldTypeNamespaceId === undefined + ? undefined + : fieldTypeNamespaceId === 'unbound' + ? '__unbound__' + : fieldTypeNamespaceId; + if ( + normalizedQualifier !== undefined && + !input.modelMappingsByCoordinate.has(modelCoordinateKey(normalizedQualifier, fieldTypeName)) + ) { + diagnostics.push({ + code: 'PSL_INVALID_RELATION_TARGET', + message: `Relation field "${model.name}.${relationAttribute.field.name}" references unknown model "${qualifiedTypeName}"`, + sourceId, + span: relationAttribute.field.span, + }); + continue; } const parsedRelation = parseRelationAttribute({ @@ -1201,7 +1213,12 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult continue; } - const targetMapping = input.modelMappings.get(fieldTypeName); + const targetMapping = + normalizedQualifier !== undefined + ? input.modelMappingsByCoordinate.get( + modelCoordinateKey(normalizedQualifier, fieldTypeName), + ) + : input.modelMappings.get(fieldTypeName); if (!targetMapping) { diagnostics.push({ code: 'PSL_INVALID_RELATION_TARGET', @@ -1269,7 +1286,10 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult }) : undefined; - const targetNamespaceId = input.modelNamespaceIds.get(targetMapping.model.name); + const targetNamespaceId = + normalizedQualifier !== undefined + ? normalizedQualifier + : input.modelNamespaceIds.get(targetMapping.model.name); foreignKeyNodes.push({ columns: localColumns, references: { @@ -1289,6 +1309,7 @@ function buildModelNodeFromPsl(input: BuildModelNodeInput): BuildModelNodeResult declaringTableName: tableName, targetModelName: targetMapping.model.name, targetTableName: targetMapping.tableName, + ...ifDefined('targetNamespaceId', targetNamespaceId), ...ifDefined('relationName', parsedRelation.relationName), localColumns, referencedColumns, @@ -1528,16 +1549,20 @@ function resolvePolymorphism( ): Record { let patched = models; + const coordinateFor = (modelName: string): string => + modelCoordinateKey(modelNamespaceIds.get(modelName) ?? defaultNamespaceId, modelName); + // STI variant columns were materialised onto the base storage table so the // variants' `storage.fields` resolve. They are storage-only on the base — the // domain field belongs to the variant — so strip them from the base model's // domain + storage field maps (the table column, built upstream, stays). for (const [baseName, fieldNames] of stiBaseFieldsByBase) { - const baseModel = patched[baseName]; + const baseKey = coordinateFor(baseName); + const baseModel = patched[baseKey]; if (!baseModel || fieldNames.length === 0) continue; patched = { ...patched, - [baseName]: stripStorageOnlyDomainFields(baseModel, fieldNames), + [baseKey]: stripStorageOnlyDomainFields(baseModel, fieldNames), }; } @@ -1552,7 +1577,7 @@ function resolvePolymorphism( continue; } - const model = patched[modelName]; + const model = patched[coordinateFor(modelName)]; if (!model) continue; if (!Object.hasOwn(model.fields, decl.fieldName)) { @@ -1597,7 +1622,7 @@ function resolvePolymorphism( patched = { ...patched, - [modelName]: { ...model, discriminator: { field: decl.fieldName }, variants }, + [coordinateFor(modelName)]: { ...model, discriminator: { field: decl.fieldName }, variants }, }; } @@ -1626,7 +1651,7 @@ function resolvePolymorphism( continue; } - const variantModel = patched[variantName]; + const variantModel = patched[coordinateFor(variantName)]; if (!variantModel) continue; const baseMapping = modelMappings.get(baseDecl.baseName); @@ -1646,7 +1671,7 @@ function resolvePolymorphism( patched = { ...patched, - [variantName]: stripStorageOnlyDomainFields( + [coordinateFor(variantName)]: stripStorageOnlyDomainFields( patchedVariant, syntheticPkFieldsByVariant.get(variantName) ?? [], ), @@ -1883,6 +1908,7 @@ export function interpretPslDocumentToSqlContract( // remains the input to the rest of the interpreter so non-namespace // concerns stay structurally identical to before. const models: PslModel[] = []; + const modelEntries: ModelNamespaceEntry[] = []; const modelNamespaceIds = new Map(); for (const namespace of input.document.ast.namespaces) { const resolvedNamespaceId = resolveNamespaceIdForSqlTarget({ @@ -1891,11 +1917,13 @@ export function interpretPslDocumentToSqlContract( }); for (const model of namespace.models) { models.push(model); + modelEntries.push({ model, namespaceId: resolvedNamespaceId }); if (resolvedNamespaceId !== undefined) { modelNamespaceIds.set(model.name, resolvedNamespaceId); } } } + const defaultNamespaceId = input.target.defaultNamespaceId; // Top-level enums (the __unspecified__ bucket) route to `storageTypes`; // enums inside a named namespace block route to `namespaceTypes[nsId]`. const topLevelEnums = input.document.ast.namespaces @@ -1992,7 +2020,20 @@ export function interpretPslDocumentToSqlContract( const storageTypes = { ...enumResult.storageTypes, ...namedTypeResult.storageTypes }; - const modelMappings = buildModelMappings(models, diagnostics, sourceId); + const modelMappingsByCoordinate = buildModelMappings( + modelEntries, + defaultNamespaceId, + diagnostics, + sourceId, + ); + // Bare-name view for unqualified relation targets and polymorphism, where + // resolution is by bare model name. When a bare name is shared across + // namespaces this collapses to the last entry; qualified relation targets + // and per-model lowering use the coordinate-keyed map above instead. + const modelMappings = new Map(); + for (const mapping of modelMappingsByCoordinate.values()) { + modelMappings.set(mapping.model.name, mapping); + } const modelNodes: ModelNode[] = []; const fkRelationMetadata: FkRelationMetadata[] = []; const backrelationCandidates: ModelBackrelationCandidate[] = []; @@ -2001,8 +2042,9 @@ export function interpretPslDocumentToSqlContract( // modelRelations after local back-relation matching so they bypass that step. const crossSpaceRelationsByModel = new Map(); - for (const model of models) { - const mapping = modelMappings.get(model.name); + for (const { model, namespaceId } of modelEntries) { + const coordinate = modelCoordinateKey(namespaceId ?? defaultNamespaceId, model.name); + const mapping = modelMappingsByCoordinate.get(coordinate); if (!mapping) { continue; } @@ -2010,6 +2052,7 @@ export function interpretPslDocumentToSqlContract( model, mapping, modelMappings, + modelMappingsByCoordinate, modelNames, compositeTypeNames, enumTypeDescriptors: allEnumTypeDescriptors, @@ -2026,15 +2069,12 @@ export function interpretPslDocumentToSqlContract( diagnostics, modelNamespaceIds, }); - const resolvedNamespaceId = modelNamespaceIds.get(model.name); modelNodes.push( - resolvedNamespaceId !== undefined - ? { ...result.modelNode, namespaceId: resolvedNamespaceId } - : result.modelNode, + namespaceId !== undefined ? { ...result.modelNode, namespaceId } : result.modelNode, ); fkRelationMetadata.push(...result.fkRelationMetadata); backrelationCandidates.push(...result.backrelationCandidates); - modelResolvedFields.set(model.name, result.resolvedFields); + modelResolvedFields.set(coordinate, result.resolvedFields); if (result.crossSpaceRelations.length > 0) { const existing = crossSpaceRelationsByModel.get(model.name) ?? []; crossSpaceRelationsByModel.set(model.name, [...existing, ...result.crossSpaceRelations]); @@ -2135,15 +2175,17 @@ export function interpretPslDocumentToSqlContract( })), }); + // Keyed by `(namespaceId, modelName)` coordinate so two models that share a + // bare name across namespaces stay distinct through the patch/polymorphism + // passes; only a genuine same-namespace duplicate is an error. const modelsForPatch: Record = {}; - for (const namespaceSlice of Object.values(contract.domain.namespaces)) { + for (const [namespaceId, namespaceSlice] of Object.entries(contract.domain.namespaces)) { for (const [modelName, model] of Object.entries(namespaceSlice.models)) { - if (Object.hasOwn(modelsForPatch, modelName)) { - throw new Error( - `duplicate model name "${modelName}" across domain namespaces during PSL interpretation`, - ); + const coordinate = modelCoordinateKey(namespaceId, modelName); + if (Object.hasOwn(modelsForPatch, coordinate)) { + throw new Error(`duplicate model "${namespaceId}.${modelName}" during PSL interpretation`); } - modelsForPatch[modelName] = model; + modelsForPatch[coordinate] = model; } } let patchedModels = patchModelDomainFields(modelsForPatch, modelResolvedFields); @@ -2188,7 +2230,7 @@ export function interpretPslDocumentToSqlContract( models: Object.fromEntries( Object.entries(namespaceSlice.models).map(([modelName, model]) => [ modelName, - patchedModels[modelName] ?? model, + patchedModels[modelCoordinateKey(namespaceId, modelName)] ?? model, ]), ), ...(namespaceSlice.valueObjects !== undefined diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts index e073dd178f..9e98794456 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-field-resolution.ts @@ -42,6 +42,24 @@ export type ModelNameMapping = { readonly fieldColumns: Map; }; +/** + * A PSL model paired with its resolved namespace coordinate (undefined when + * the target leaves the model late-bound). Two models may share a bare name + * across namespaces, so structures that must distinguish them are keyed by + * the `(namespaceId, modelName)` coordinate produced by + * {@link modelCoordinateKey} rather than the bare model name. + */ +export type ModelNamespaceEntry = { + readonly model: PslModel; + readonly namespaceId: string | undefined; +}; + +const MODEL_COORDINATE_SEPARATOR = '\u0000'; + +export function modelCoordinateKey(namespaceId: string, modelName: string): string { + return `${namespaceId}${MODEL_COORDINATE_SEPARATOR}${modelName}`; +} + export interface CollectResolvedFieldsInput { readonly model: PslModel; readonly mapping: ModelNameMapping; @@ -424,12 +442,13 @@ export function collectResolvedFields(input: CollectResolvedFieldsInput): Resolv } export function buildModelMappings( - models: readonly PslModel[], + modelEntries: readonly ModelNamespaceEntry[], + defaultNamespaceId: string, diagnostics: ContractSourceDiagnostic[], sourceId: string, ): Map { const result = new Map(); - for (const model of models) { + for (const { model, namespaceId } of modelEntries) { const mapAttribute = getAttribute(model.attributes, 'map'); const tableName = parseMapName({ attribute: mapAttribute, @@ -452,7 +471,7 @@ export function buildModelMappings( }); fieldColumns.set(field.name, columnName); } - result.set(model.name, { + result.set(modelCoordinateKey(namespaceId ?? defaultNamespaceId, model.name), { model, tableName, fieldColumns, diff --git a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts index 01863841eb..114f5b79e2 100644 --- a/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts +++ b/packages/2-sql/2-authoring/contract-psl/src/psl-relation-resolution.ts @@ -42,6 +42,8 @@ export type FkRelationMetadata = { readonly declaringTableName: string; readonly targetModelName: string; readonly targetTableName: string; + /** Resolved namespace coordinate of the related model, when known. */ + readonly targetNamespaceId?: string; readonly relationName?: string; readonly localColumns: readonly string[]; readonly referencedColumns: readonly string[]; @@ -251,6 +253,7 @@ export function indexFkRelations(input: { fieldName: relation.declaringFieldName, toModel: relation.targetModelName, toTable: relation.targetTableName, + ...ifDefined('toNamespaceId', relation.targetNamespaceId), cardinality: 'N:1', on: { parentTable: relation.declaringTableName, diff --git a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts index 10eb9de475..8ab9066a5d 100644 --- a/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts +++ b/packages/2-sql/2-authoring/contract-psl/test/interpreter.namespaces.test.ts @@ -1,7 +1,7 @@ -import type { Contract } from '@prisma-next/contract/types'; +import type { Contract, ContractModel } from '@prisma-next/contract/types'; import { coreHash, profileHash } from '@prisma-next/contract/types'; import { parsePslDocument } from '@prisma-next/psl-parser'; -import type { ForeignKey, SqlStorage } from '@prisma-next/sql-contract/types'; +import type { ForeignKey, SqlModelStorage, SqlStorage } from '@prisma-next/sql-contract/types'; import { blindCast } from '@prisma-next/utils/casts'; import { describe, expect, it } from 'vitest'; import { interpretPslDocumentToSqlContract } from '../src/interpreter'; @@ -173,6 +173,65 @@ namespace auth { }); }); + it('lowers the same bare table name in two namespaces with differing columns and a cross-namespace FK', () => { + const document = parsePslDocument({ + schema: `namespace public { + model User { + id Int @id + email String + @@map("users") + } + model Profile { + id Int @id + userId Int + user auth.User @relation(fields: [userId], references: [id]) + @@map("profile") + } +} + +namespace auth { + model User { + id Int @id + token String + @@map("users") + } +} +`, + sourceId: 'schema.prisma', + }); + + const result = interpretPslDocumentToSqlContract({ ...baseInput, document }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const storage = result.value.storage as SqlStorage; + const publicUsers = storage.namespaces['public']?.entries.table['users']; + const authUsers = storage.namespaces['auth']?.entries.table['users']; + expect(Object.keys(publicUsers?.columns ?? {}).sort()).toEqual(['email', 'id']); + expect(Object.keys(authUsers?.columns ?? {}).sort()).toEqual(['id', 'token']); + + const fks: readonly ForeignKey[] = + storage.namespaces['public']?.entries.table['profile']?.foreignKeys ?? []; + expect(fks.length).toBe(1); + expect(fks[0]).toMatchObject({ target: { namespaceId: 'auth', tableName: 'users' } }); + + const publicModels = result.value.domain.namespaces['public']?.models as + | Record> + | undefined; + const authModels = result.value.domain.namespaces['auth']?.models as + | Record> + | undefined; + expect(publicModels?.['User']?.storage.table).toBe('users'); + expect(publicModels?.['User']?.storage.namespaceId).toBe('public'); + expect(authModels?.['User']?.storage.table).toBe('users'); + expect(authModels?.['User']?.storage.namespaceId).toBe('auth'); + expect(publicModels?.['Profile']?.relations?.['user']?.to).toEqual({ + namespace: 'auth', + model: 'User', + }); + }); + it('emits PSL_INVALID_RELATION_TARGET when qualifier names a non-existent namespace', () => { const document = parsePslDocument({ schema: `namespace public { diff --git a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts index 683d7ac4a3..8ffd3405bc 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/build-contract.ts @@ -124,14 +124,23 @@ function assertStorageSemantics( function assertKnownTargetModel( modelsByName: ReadonlyMap, + modelsByCoordinate: ReadonlyMap, sourceModelName: string, targetModelName: string, + targetNamespaceId: string | undefined, context: string, ): ModelNode { - const targetModel = modelsByName.get(targetModelName); + const targetModel = + targetNamespaceId !== undefined && targetNamespaceId.length > 0 + ? modelsByCoordinate.get(`${targetNamespaceId}:${targetModelName}`) + : modelsByName.get(targetModelName); if (!targetModel) { + const qualified = + targetNamespaceId !== undefined && targetNamespaceId.length > 0 + ? `${targetNamespaceId}.${targetModelName}` + : targetModelName; throw new Error( - `${context} on model "${sourceModelName}" references unknown model "${targetModelName}"`, + `${context} on model "${sourceModelName}" references unknown model "${qualified}"`, ); } return targetModel; @@ -369,6 +378,8 @@ export function buildSqlContractFromDefinition( const target = definition.target.targetId; const defaultNamespaceId = definition.target.defaultNamespaceId; const targetFamily = 'sql'; + const resolveNamespaceId = (m: ModelNode): string => + m.namespaceId !== undefined && m.namespaceId.length > 0 ? m.namespaceId : defaultNamespaceId; const modelsByName = new Map(definition.models.map((m) => [m.modelName, m])); const tableNamespaceByName = new Map( definition.models.map((m) => [ @@ -376,13 +387,19 @@ export function buildSqlContractFromDefinition( m.namespaceId !== undefined && m.namespaceId.length > 0 ? m.namespaceId : defaultNamespaceId, ]), ); + const modelsByCoordinate = new Map( + definition.models.map((m) => [`${resolveNamespaceId(m)}:${m.modelName}`, m]), + ); const tablesByNamespace: Record> = {}; - const tableNameToNamespaceId = new Map(); const modelNameToNamespaceId = new Map(); const executionDefaults: ExecutionMutationDefault[] = []; const modelsByNamespace: Record> = {}; - const roots: Record = {}; + const rootEntries: Array<{ + readonly tableName: string; + readonly namespaceId: string; + readonly ref: CrossReference; + }> = []; for (const semanticModel of definition.models) { const tableName = semanticModel.tableName; @@ -394,7 +411,11 @@ export function buildSqlContractFromDefinition( // STI variants share the base table; the base model already owns this // table name and its root, so the variant contributes neither. if (!semanticModel.sharesBaseTable) { - roots[tableName] = crossRef(semanticModel.modelName, namespaceId); + rootEntries.push({ + tableName, + namespaceId, + ref: crossRef(semanticModel.modelName, namespaceId), + }); } // --- Build storage table --- @@ -498,8 +519,10 @@ export function buildSqlContractFromDefinition( const targetModel = assertKnownTargetModel( modelsByName, + modelsByCoordinate, semanticModel.modelName, fk.references.model, + fk.references.namespaceId, 'Foreign key', ); assertTargetTableMatches( @@ -537,14 +560,6 @@ export function buildSqlContractFromDefinition( // materialised onto the base `ModelNode`, so the variant builds a domain // model (below) but no storage table of its own. if (!semanticModel.sharesBaseTable) { - const existingNs = tableNameToNamespaceId.get(tableName); - if (existingNs !== undefined && existingNs !== namespaceId) { - throw new Error( - `buildSqlContractFromDefinition: table "${tableName}" is mapped in namespace "${namespaceId}" but already exists in namespace "${existingNs}".`, - ); - } - tableNameToNamespaceId.set(tableName, namespaceId); - const checksForTable: CheckConstraintInput[] = Object.entries(columns).flatMap( ([columnName, col]) => { const valueSet = col.valueSet; @@ -624,8 +639,10 @@ export function buildSqlContractFromDefinition( const targetModel = assertKnownTargetModel( modelsByName, + modelsByCoordinate, semanticModel.modelName, relation.toModel, + relation.toNamespaceId, 'Relation', ); assertTargetTableMatches(semanticModel.modelName, targetModel, relation.toTable, 'Relation'); @@ -636,7 +653,9 @@ export function buildSqlContractFromDefinition( const to = crossRef( relation.toModel, - resolveModelNamespaceId(targetModel, modelNameToNamespaceId, defaultNamespaceId), + relation.toNamespaceId !== undefined && relation.toNamespaceId.length > 0 + ? relation.toNamespaceId + : resolveModelNamespaceId(targetModel, modelNameToNamespaceId, defaultNamespaceId), ); const on = { localFields: relation.on.parentColumns.map((col) => columnToField.get(col) ?? col), @@ -684,6 +703,24 @@ export function buildSqlContractFromDefinition( // --- Assemble contract --- + // Aggregate roots are keyed by bare storage table name. When two models in + // different namespaces map to the same bare table name, the bare key would + // collide (last write wins, silently dropping a root), so those entries fall + // back to a namespace-qualified key. Single-namespace contracts never + // collide and keep their bare keys unchanged. + const rootTableNameCounts = new Map(); + for (const entry of rootEntries) { + rootTableNameCounts.set(entry.tableName, (rootTableNameCounts.get(entry.tableName) ?? 0) + 1); + } + const roots: Record = {}; + for (const entry of rootEntries) { + const key = + (rootTableNameCounts.get(entry.tableName) ?? 0) > 1 + ? `${entry.namespaceId}.${entry.tableName}` + : entry.tableName; + roots[key] = entry.ref; + } + // Normalise raw codec-triple inputs to the `kind: 'codec-instance'` // discriminator shape before hashing so the storageHash matches the // persisted JSON envelope produced from the SqlStorage class instance diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts index c044052ba1..adcd8e829b 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-definition.ts @@ -77,6 +77,14 @@ export interface RelationNode { readonly fieldName: string; readonly toModel: string; readonly toTable: string; + /** + * Namespace coordinate of the related model. When omitted the assembler + * resolves the coordinate from the referenced model node's own + * `namespaceId`; the field exists so authoring paths that already know the + * target namespace can stamp it explicitly — required to disambiguate a + * relation to a model whose bare name also exists in another namespace. + */ + readonly toNamespaceId?: string; readonly cardinality: '1:1' | '1:N' | 'N:1' | 'N:M'; /** * Contract-space identity of the related model. When present, the diff --git a/packages/2-sql/2-authoring/contract-ts/test/contract-builder.cross-namespace-same-table.test.ts b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.cross-namespace-same-table.test.ts new file mode 100644 index 0000000000..0118fd7888 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-ts/test/contract-builder.cross-namespace-same-table.test.ts @@ -0,0 +1,117 @@ +import type { TargetPackRef } from '@prisma-next/framework-components/components'; +import type { ForeignKey, SqlStorage } from '@prisma-next/sql-contract/types'; +import { validateSqlContractFully } from '@prisma-next/sql-contract/validators'; +import { describe, expect, it } from 'vitest'; +import { buildSqlContractFromDefinition } from '../src/contract-builder'; +import type { ModelNode } from '../src/contract-definition'; + +const postgresTargetPack: TargetPackRef<'sql', 'postgres'> = { + kind: 'target', + id: 'postgres', + familyId: 'sql', + targetId: 'postgres', + version: '0.0.1', + defaultNamespaceId: 'public', +}; + +const idDescriptor = { codecId: 'pg/int4@1', nativeType: 'int4' } as const; +const textDescriptor = { codecId: 'pg/text@1', nativeType: 'text' } as const; + +const publicUser: ModelNode = { + modelName: 'User', + tableName: 'users', + namespaceId: 'public', + fields: [ + { fieldName: 'id', columnName: 'id', descriptor: idDescriptor, nullable: false }, + { fieldName: 'email', columnName: 'email', descriptor: textDescriptor, nullable: false }, + ], + id: { columns: ['id'] }, +}; + +const authUser: ModelNode = { + modelName: 'User', + tableName: 'users', + namespaceId: 'auth', + fields: [ + { fieldName: 'id', columnName: 'id', descriptor: idDescriptor, nullable: false }, + { fieldName: 'token', columnName: 'token', descriptor: textDescriptor, nullable: false }, + ], + id: { columns: ['id'] }, +}; + +const profile: ModelNode = { + modelName: 'Profile', + tableName: 'profile', + namespaceId: 'public', + fields: [ + { fieldName: 'id', columnName: 'id', descriptor: idDescriptor, nullable: false }, + { fieldName: 'userId', columnName: 'userId', descriptor: idDescriptor, nullable: false }, + ], + id: { columns: ['id'] }, + foreignKeys: [ + { + columns: ['userId'], + references: { model: 'User', table: 'users', columns: ['id'], namespaceId: 'auth' }, + }, + ], + relations: [ + { + fieldName: 'user', + toModel: 'User', + toTable: 'users', + toNamespaceId: 'auth', + cardinality: 'N:1', + on: { + parentTable: 'profile', + parentColumns: ['userId'], + childTable: 'users', + childColumns: ['id'], + }, + }, + ], +}; + +describe('same bare table name across namespaces with a cross-namespace FK', () => { + const contract = buildSqlContractFromDefinition({ + target: postgresTargetPack, + namespaces: ['public', 'auth'], + models: [publicUser, profile, authUser], + }); + const storage = contract.storage as SqlStorage; + + it('lowers both same-named tables into their own namespace with differing columns', () => { + const publicUsers = storage.namespaces['public']?.entries.table['users']; + const authUsers = storage.namespaces['auth']?.entries.table['users']; + expect(publicUsers).toBeDefined(); + expect(authUsers).toBeDefined(); + + expect(Object.keys(publicUsers!.columns).sort()).toEqual(['email', 'id']); + expect(Object.keys(authUsers!.columns).sort()).toEqual(['id', 'token']); + }); + + it('lowers the cross-namespace FK with a target coordinate pointing at the other namespace', () => { + const fks: readonly ForeignKey[] = + storage.namespaces['public']?.entries.table['profile']?.foreignKeys ?? []; + expect(fks.length).toBe(1); + expect(fks[0]).toMatchObject({ + target: { namespaceId: 'auth', tableName: 'users', columns: ['id'] }, + }); + }); + + it('resolves the domain relation to the explicit cross-namespace coordinate', () => { + const profileModel = contract.domain.namespaces['public']?.models['Profile']; + expect(profileModel?.relations?.['user']?.to).toEqual({ namespace: 'auth', model: 'User' }); + }); + + it('keeps every same-named model an aggregate root', () => { + const rootValues = Object.values(contract.roots); + expect(rootValues).toContainEqual({ namespace: 'public', model: 'User' }); + expect(rootValues).toContainEqual({ namespace: 'auth', model: 'User' }); + expect(rootValues).toContainEqual({ namespace: 'public', model: 'Profile' }); + }); + + it('round-trips through JSON and validates without error', () => { + const json: unknown = JSON.parse(JSON.stringify(contract)); + expect(() => validateSqlContractFully(json)).not.toThrow(); + }); +}); diff --git a/packages/2-sql/4-lanes/relational-core/DEVELOPING.md b/packages/2-sql/4-lanes/relational-core/DEVELOPING.md index e95c14db22..a52c04133e 100644 --- a/packages/2-sql/4-lanes/relational-core/DEVELOPING.md +++ b/packages/2-sql/4-lanes/relational-core/DEVELOPING.md @@ -13,10 +13,10 @@ Every codec-bearing AST node carries `codec: CodecRef | undefined` — a seriali ### Builder construction sites -Column-bound construction sites derive `codec` from the contract via `descriptors.codecRefForColumn(table, column)`: +Column-bound construction sites derive `codec` from the contract via `descriptors.codecRefForColumn(namespace, table, column)`: ```ts -const ref = descriptors.codecRefForColumn('document', 'embedding'); +const ref = descriptors.codecRefForColumn('public', 'document', 'embedding'); // → { codecId: 'pg/vector@1', typeParams: { length: 1536 } } ParamRef.of(value, { codec: ref }); diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts b/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts index a617f26c51..baeecb5db0 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/codec-types.ts @@ -81,13 +81,15 @@ export interface Codec< * * Built once at `ExecutionContext` construction time by walking the contract's `storage.tables[].columns[]` and resolving each column through its descriptor's factory. Runtime encode/decode dispatch resolves codecs via `forCodecRef(ref)` — the single dispatch shape for AST-bound codec resolution. * - * `forColumn(table, column)` is retained for build-time helpers that need column-keyed lookup (e.g. projection stamping); runtime dispatch routes through `forCodecRef`. + * `forColumn(namespace, table, column)` is retained for build-time helpers that need column-keyed lookup (e.g. projection stamping); runtime dispatch routes through `forCodecRef`. */ export interface ContractCodecRegistry { /** - * Resolve the codec for `(table, column)`. Returns the per-instance parameterized codec for parameterized columns, the shared codec for non-parameterized columns, or `undefined` if the column is unknown or the codec isn't registered. + * Resolve the codec for `(namespace, table, column)`. Returns the per-instance parameterized codec for parameterized columns, the shared codec for non-parameterized columns, or `undefined` if the column is unknown or the codec isn't registered. + * + * The `namespaceId` coordinate leads and is always supplied — the column is resolved strictly within that namespace, so two same-bare-named tables across namespaces resolve to their own per-namespace codecs. */ - forColumn(table: string, column: string): Codec | undefined; + forColumn(namespaceId: string, table: string, column: string): Codec | undefined; /** * Resolve a codec by {@link CodecRef}. The single dispatch shape for AST-bound codec resolution — every codec-bearing AST node carries a `CodecRef` that resolves through this method via the per-`ExecutionContext` `AstCodecResolver`. Two refs with the same `codecId` and structurally equal `typeParams` (regardless of object key order) return the same memoised codec instance. Throws `RUNTIME.CODEC_DESCRIPTOR_MISSING` for unknown `codecId`s and `RUNTIME.TYPE_PARAMS_INVALID` on `paramsSchema` rejection. diff --git a/packages/2-sql/4-lanes/relational-core/src/codec-descriptor-registry.ts b/packages/2-sql/4-lanes/relational-core/src/codec-descriptor-registry.ts index b1d0dbda0e..7e87d1eab1 100644 --- a/packages/2-sql/4-lanes/relational-core/src/codec-descriptor-registry.ts +++ b/packages/2-sql/4-lanes/relational-core/src/codec-descriptor-registry.ts @@ -45,9 +45,9 @@ export function buildCodecDescriptorRegistry( descriptorFor(codecId: string): AnyDescriptor | undefined { return byId.get(codecId); }, - codecRefForColumn(table: string, column: string): CodecRef | undefined { + codecRefForColumn(namespaceId: string, table: string, column: string): CodecRef | undefined { if (!storage) return undefined; - return codecRefForStorageColumn(storage, table, column); + return codecRefForStorageColumn(storage, namespaceId, table, column); }, *values(): IterableIterator { yield* byId.values(); diff --git a/packages/2-sql/4-lanes/relational-core/src/codec-ref-for-column.ts b/packages/2-sql/4-lanes/relational-core/src/codec-ref-for-column.ts index 09114fb193..db42683927 100644 --- a/packages/2-sql/4-lanes/relational-core/src/codec-ref-for-column.ts +++ b/packages/2-sql/4-lanes/relational-core/src/codec-ref-for-column.ts @@ -1,10 +1,10 @@ import type { JsonValue } from '@prisma-next/contract/types'; import type { CodecRef } from '@prisma-next/framework-components/codec'; +import { resolveStorageTable } from '@prisma-next/sql-contract/resolve-storage-table'; import { isPostgresEnumStorageEntry, isStorageTypeInstance, type SqlStorage, - type StorageTable, } from '@prisma-next/sql-contract/types'; /** @@ -17,21 +17,20 @@ import { * - non-parameterized column → `{codecId}` with `typeParams` undefined. * * Returns `undefined` when the table or column is unknown, or when a `typeRef` column references a `storage.types` entry that does not exist. + * + * `namespaceId` leads the coordinate args and is always supplied: every + * model/table sits in an explicit namespace, so the table is resolved strictly + * within that namespace (see {@link resolveStorageTable}). */ export function codecRefForStorageColumn( storage: SqlStorage, + namespaceId: string, tableName: string, columnName: string, ): CodecRef | undefined { - let tableDef: StorageTable | undefined; - for (const ns of Object.values(storage.namespaces)) { - const candidate = ns.entries.table[tableName] as StorageTable | undefined; - if (candidate !== undefined) { - tableDef = candidate; - break; - } - } - if (!tableDef) return undefined; + const resolved = resolveStorageTable(storage, tableName, namespaceId); + if (resolved === undefined) return undefined; + const tableDef = resolved.table; const columnDef = tableDef.columns[columnName]; if (!columnDef) return undefined; if (columnDef.typeRef !== undefined) { diff --git a/packages/2-sql/4-lanes/relational-core/src/query-lane-context.ts b/packages/2-sql/4-lanes/relational-core/src/query-lane-context.ts index 517c2cdc74..397b3aea05 100644 --- a/packages/2-sql/4-lanes/relational-core/src/query-lane-context.ts +++ b/packages/2-sql/4-lanes/relational-core/src/query-lane-context.ts @@ -15,15 +15,17 @@ export interface CodecDescriptorRegistry { /** * Derive the canonical {@link CodecRef} for a contract `(table, column)`. The builder side calls this at AST construction time to stamp `codec` onto every column-bound `ParamRef` / `ProjectionItem`; the runtime side uses the result as the cache key into the content-keyed codec resolver. * - * Resolution rules over `storage.tables[table].columns[column]`: + * Resolution rules over `storage.namespaces[namespaceId].tables[table].columns[column]`: * * - `typeRef` column → emit `{codecId, typeParams}` from `storage.types[typeRef]` (multiple columns sharing the typeRef produce the same ref → same memoised codec). * - inline `typeParams` column → emit `{codecId, typeParams}` from the column itself. * - non-parameterized column → emit `{codecId}` with `typeParams` undefined (keys as `${codecId}:undefined` → one shared codec). * - * Returns `undefined` when the registry was built without contract storage (package-scoped registries used purely as descriptor lookups), when the table or column is unknown, or when the column declares a `typeRef` that the storage doesn't define. + * The `namespaceId` coordinate leads and is always supplied — the table is resolved strictly within that namespace, so two same-bare-named tables in different namespaces resolve to their own per-namespace columns/codecs without colliding. + * + * Returns `undefined` when the registry was built without contract storage (package-scoped registries used purely as descriptor lookups), when the table or column is unknown in the namespace, or when the column declares a `typeRef` that the storage doesn't define. */ - codecRefForColumn(table: string, column: string): CodecRef | undefined; + codecRefForColumn(namespaceId: string, table: string, column: string): CodecRef | undefined; /** * All registered descriptors. Used by `validateCodecRegistryCompleteness` and other startup-time consumers that enumerate descriptors. */ diff --git a/packages/2-sql/4-lanes/relational-core/test/codec-descriptor-registry.test.ts b/packages/2-sql/4-lanes/relational-core/test/codec-descriptor-registry.test.ts index 518d0252ca..2fb09d686d 100644 --- a/packages/2-sql/4-lanes/relational-core/test/codec-descriptor-registry.test.ts +++ b/packages/2-sql/4-lanes/relational-core/test/codec-descriptor-registry.test.ts @@ -91,7 +91,7 @@ describe('buildCodecDescriptorRegistry — codecRefForColumn', () => { it('returns undefined when the registry was built without storage', () => { const registry = buildCodecDescriptorRegistry(descriptors); - expect(registry.codecRefForColumn('Doc', 'embedding')).toBeUndefined(); + expect(registry.codecRefForColumn(UNBOUND_NAMESPACE_ID, 'Doc', 'embedding')).toBeUndefined(); }); it('derives `{codecId, typeParams}` from `storage.types` for a typeRef column', () => { @@ -123,7 +123,7 @@ describe('buildCodecDescriptorRegistry — codecRefForColumn', () => { }); const registry = buildCodecDescriptorRegistry(descriptors, storage); - expect(registry.codecRefForColumn('Doc', 'embedding')).toEqual({ + expect(registry.codecRefForColumn(UNBOUND_NAMESPACE_ID, 'Doc', 'embedding')).toEqual({ codecId: 'pg/vector@1', typeParams: { length: 1536 }, }); @@ -150,7 +150,7 @@ describe('buildCodecDescriptorRegistry — codecRefForColumn', () => { }); const registry = buildCodecDescriptorRegistry(descriptors, storage); - expect(registry.codecRefForColumn('Doc', 'embedding')).toEqual({ + expect(registry.codecRefForColumn(UNBOUND_NAMESPACE_ID, 'Doc', 'embedding')).toEqual({ codecId: 'pg/vector@1', typeParams: { length: 768 }, }); @@ -176,7 +176,7 @@ describe('buildCodecDescriptorRegistry — codecRefForColumn', () => { }); const registry = buildCodecDescriptorRegistry(descriptors, storage); - const ref = registry.codecRefForColumn('User', 'email'); + const ref = registry.codecRefForColumn(UNBOUND_NAMESPACE_ID, 'User', 'email'); expect(ref).toEqual({ codecId: 'pg/text@1' }); expect(ref?.typeParams).toBeUndefined(); }); @@ -197,8 +197,10 @@ describe('buildCodecDescriptorRegistry — codecRefForColumn', () => { }); const registry = buildCodecDescriptorRegistry(descriptors, storage); - expect(registry.codecRefForColumn('User', 'nope')).toBeUndefined(); - expect(registry.codecRefForColumn('NoSuchTable', 'whatever')).toBeUndefined(); + expect(registry.codecRefForColumn(UNBOUND_NAMESPACE_ID, 'User', 'nope')).toBeUndefined(); + expect( + registry.codecRefForColumn(UNBOUND_NAMESPACE_ID, 'NoSuchTable', 'whatever'), + ).toBeUndefined(); }); it('returns undefined when the typeRef points at an undefined storage type', () => { @@ -222,6 +224,64 @@ describe('buildCodecDescriptorRegistry — codecRefForColumn', () => { }); const registry = buildCodecDescriptorRegistry(descriptors, storage); - expect(registry.codecRefForColumn('Doc', 'embedding')).toBeUndefined(); + expect(registry.codecRefForColumn(UNBOUND_NAMESPACE_ID, 'Doc', 'embedding')).toBeUndefined(); + }); +}); + +describe('buildCodecDescriptorRegistry — codecRefForColumn namespace coordinate', () => { + const descriptors = [ + stub('pg/int4@1', ['int4']), + stub('pg/text@1', ['text']), + stub('pg/varchar@1', ['varchar']), + ]; + + function table(columns: Record): StorageTableInput { + return { + columns: Object.fromEntries( + Object.entries(columns).map(([name, codecId]) => [ + name, + { nativeType: codecId, codecId, nullable: false }, + ]), + ), + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; + } + + // Two namespaces declare a table with the same bare name `users` but with + // differing columns/codecs. The registry must discriminate by the namespace + // coordinate it is handed rather than scanning and first-matching. + function twoNamespaceStorage(): SqlStorage { + return new SqlStorage({ + storageHash: 'sha256:test' as SqlStorage['storageHash'], + namespaces: { + public: buildSqlNamespace({ + id: 'public', + entries: { table: { users: table({ id: 'pg/int4@1', email: 'pg/text@1' }) } }, + }), + auth: buildSqlNamespace({ + id: 'auth', + entries: { table: { users: table({ id: 'pg/int4@1', token: 'pg/varchar@1' }) } }, + }), + }, + }); + } + + it('resolves the per-namespace codec ref for a same bare table name, discriminating by coordinate', () => { + const registry = buildCodecDescriptorRegistry(descriptors, twoNamespaceStorage()); + + expect(registry.codecRefForColumn('public', 'users', 'email')).toEqual({ + codecId: 'pg/text@1', + }); + expect(registry.codecRefForColumn('auth', 'users', 'token')).toEqual({ + codecId: 'pg/varchar@1', + }); + + // A column present only in the other namespace must not resolve here — proves + // the resolution honours the coordinate rather than first-matching by name. + expect(registry.codecRefForColumn('public', 'users', 'token')).toBeUndefined(); + expect(registry.codecRefForColumn('auth', 'users', 'email')).toBeUndefined(); }); }); diff --git a/packages/2-sql/4-lanes/relational-core/test/codec-ref-for-column.test.ts b/packages/2-sql/4-lanes/relational-core/test/codec-ref-for-column.test.ts new file mode 100644 index 0000000000..1e12b38c26 --- /dev/null +++ b/packages/2-sql/4-lanes/relational-core/test/codec-ref-for-column.test.ts @@ -0,0 +1,79 @@ +import { + buildSqlNamespace, + SqlStorage, + type SqlStorage as SqlStorageType, + StorageTable, +} from '@prisma-next/sql-contract/types'; +import { blindCast } from '@prisma-next/utils/casts'; +import { describe, expect, it } from 'vitest'; +import { codecRefForStorageColumn } from '../src/codec-ref-for-column'; + +const STORAGE_HASH = blindCast( + 'sha256:test', +); + +function usersTable(columnName: string, codecId: string): StorageTable { + return new StorageTable({ + columns: { + id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false }, + [columnName]: { codecId, nativeType: 'text', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }); +} + +function twoNamespaceSameTableName(): SqlStorage { + return new SqlStorage({ + storageHash: STORAGE_HASH, + namespaces: { + public: buildSqlNamespace({ + id: 'public', + entries: { table: { users: usersTable('email_addr', 'pg/text@1') } }, + }), + auth: buildSqlNamespace({ + id: 'auth', + entries: { table: { users: usersTable('token_col', 'pg/int4@1') } }, + }), + }, + }); +} + +describe('codecRefForStorageColumn', () => { + it('resolves a same-bare-name column strictly within the given namespace', () => { + const storage = twoNamespaceSameTableName(); + + expect(codecRefForStorageColumn(storage, 'public', 'users', 'email_addr')).toEqual({ + codecId: 'pg/text@1', + }); + expect(codecRefForStorageColumn(storage, 'auth', 'users', 'token_col')).toEqual({ + codecId: 'pg/int4@1', + }); + }); + + it('returns undefined when the column belongs to a different namespace', () => { + const storage = twoNamespaceSameTableName(); + + expect(codecRefForStorageColumn(storage, 'public', 'users', 'token_col')).toBeUndefined(); + expect(codecRefForStorageColumn(storage, 'auth', 'users', 'email_addr')).toBeUndefined(); + }); + + it('returns undefined for an unknown column within the namespace', () => { + const storage = new SqlStorage({ + storageHash: STORAGE_HASH, + namespaces: { + public: buildSqlNamespace({ + id: 'public', + entries: { table: { users: usersTable('email_addr', 'pg/text@1') } }, + }), + }, + }); + + expect(codecRefForStorageColumn(storage, 'public', 'users', 'email_addr')).toEqual({ + codecId: 'pg/text@1', + }); + expect(codecRefForStorageColumn(storage, 'public', 'users', 'missing')).toBeUndefined(); + }); +}); diff --git a/packages/2-sql/4-lanes/sql-builder/src/exports/types.ts b/packages/2-sql/4-lanes/sql-builder/src/exports/types.ts index 22023aba72..45c26f929c 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/exports/types.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/exports/types.ts @@ -3,6 +3,7 @@ export type { ResolveRow } from '../resolve'; export type { GatedMethod, QueryContext, Scope, ScopeField, Subquery } from '../scope'; export type { Db, + Namespace, TableInAnyNamespace, TableNamesAcrossNamespaces, TableProxyContract, diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts index 154f4747ca..3f37405364 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts @@ -107,11 +107,12 @@ export interface BuilderContext { */ export function codecRefFor( ctx: BuilderContext, + namespaceId: string, tableName: string, columnName: string, ): CodecRef | undefined { if (!ctx.storage) return undefined; - return codecRefForStorageColumn(ctx.storage, tableName, columnName); + return codecRefForStorageColumn(ctx.storage, namespaceId, tableName, columnName); } export function emptyState(from: TableSource, scope: Scope): BuilderState { @@ -199,14 +200,21 @@ export function buildPlan( export function tableToScope( alias: string, table: StorageTable, - options?: { readonly storage?: SqlStorage | undefined; readonly tableName?: string | undefined }, + options?: { + readonly storage?: SqlStorage | undefined; + readonly namespaceId?: string | undefined; + readonly tableName?: string | undefined; + }, ): Scope { const storage = options?.storage; const lookupName = options?.tableName; + const namespaceId = options?.namespaceId; const fields: ScopeTable = {}; for (const [colName, col] of Object.entries(table.columns)) { const codec = - storage && lookupName ? codecRefForStorageColumn(storage, lookupName, colName) : undefined; + storage && lookupName && namespaceId !== undefined + ? codecRefForStorageColumn(storage, namespaceId, lookupName, colName) + : undefined; fields[colName] = { codecId: col.codecId, nullable: col.nullable, diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts index 1d255e52fe..5f962527cb 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/mutation-impl.ts @@ -68,6 +68,7 @@ export type UpdateSetCallback = ( export function buildParamValues( values: Record, + namespaceId: string, table: StorageTable, tableName: string, op: MutationDefaultsOp, @@ -76,12 +77,12 @@ export function buildParamValues( const params: Record = {}; for (const [col, value] of Object.entries(values)) { const column = table.columns[col]; - const codec = column ? codecRefFor(ctx, tableName, col) : undefined; + const codec = column ? codecRefFor(ctx, namespaceId, tableName, col) : undefined; params[col] = ParamRef.of(value, codec ? { codec } : undefined); } for (const def of ctx.applyMutationDefaults({ op, table: tableName, values })) { const column = table.columns[def.column]; - const codec = column ? codecRefFor(ctx, tableName, def.column) : undefined; + const codec = column ? codecRefFor(ctx, namespaceId, tableName, def.column) : undefined; params[def.column] = ParamRef.of(def.value, codec ? { codec } : undefined); } return params; @@ -129,6 +130,7 @@ export function evaluateUpdateCallback( export function buildSetExpressions( exprs: Record, + namespaceId: string, table: StorageTable, tableName: string, op: MutationDefaultsOp, @@ -138,7 +140,7 @@ export function buildSetExpressions( for (const def of ctx.applyMutationDefaults({ op, table: tableName, values: exprs })) { if (!(def.column in set)) { const column = table.columns[def.column]; - const codec = column ? codecRefFor(ctx, tableName, def.column) : undefined; + const codec = column ? codecRefFor(ctx, namespaceId, tableName, def.column) : undefined; set[def.column] = ParamRef.of(def.value, ifDefined('codec', codec)); } } @@ -155,6 +157,7 @@ export class InsertQueryImpl< { readonly #tableSource: TableSource; readonly #tableName: string; + readonly #namespaceId: string; readonly #table: StorageTable; readonly #scope: Scope; readonly #rows: ReadonlyArray>; @@ -164,6 +167,7 @@ export class InsertQueryImpl< constructor( tableSource: TableSource, + namespaceId: string, table: StorageTable, scope: Scope, rows: ReadonlyArray>, @@ -175,6 +179,7 @@ export class InsertQueryImpl< super(ctx); this.#tableSource = tableSource; this.#tableName = tableSource.name; + this.#namespaceId = namespaceId; this.#table = table; this.#scope = scope; this.#rows = rows; @@ -195,6 +200,7 @@ export class InsertQueryImpl< } return new InsertQueryImpl( this.#tableSource, + this.#namespaceId, this.#table, this.#scope, this.#rows, @@ -218,6 +224,7 @@ export class InsertQueryImpl< ): InsertQuery { return new InsertQueryImpl( this.#tableSource, + this.#namespaceId, this.#table, this.#scope, this.#rows, @@ -237,7 +244,14 @@ export class InsertQueryImpl< } const paramRows = this.#rows.map((rowValues) => - buildParamValues(rowValues, this.#table, this.#tableName, 'create', this.ctx), + buildParamValues( + rowValues, + this.#namespaceId, + this.#table, + this.#tableName, + 'create', + this.ctx, + ), ); let ast = InsertAst.into(this.#tableSource).withRows(paramRows); diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/resolve-table.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/resolve-table.ts index 6d78941cba..824cfa3ae4 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/resolve-table.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/resolve-table.ts @@ -16,3 +16,15 @@ export function resolveTableForFlatName( } return resolved; } + +export function resolveTableInNamespace( + storage: SqlStorage, + namespaceId: string, + tableName: string, +): StorageTable | undefined { + const namespace = storage.namespaces[namespaceId]; + if (namespace === undefined || !Object.hasOwn(namespace.entries.table, tableName)) { + return undefined; + } + return namespace.entries.table[tableName]; +} diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/sql.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/sql.ts index c41d309cf9..febd9ff588 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/sql.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/sql.ts @@ -4,7 +4,7 @@ import type { RawCodecInferer } from '@prisma-next/sql-relational-core/expressio import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; import type { Db, TableProxyContract } from '../types/db'; import type { BuilderContext } from './builder-base'; -import { resolveTableForFlatName } from './resolve-table'; +import { resolveTableForFlatName, resolveTableInNamespace } from './resolve-table'; import { TableProxyImpl } from './table-proxy-impl'; export interface SqlOptions & TableProxyContract> { @@ -26,9 +26,26 @@ export function sql & TableProxyContract>( rawCodecInferer, }; + const { storage } = context.contract; + return new Proxy({} as Db, { get(_target, prop: string) { - const resolved = resolveTableForFlatName(context.contract.storage, prop); + if (Object.hasOwn(storage.namespaces, prop)) { + const namespaceId = prop; + return new Proxy( + {}, + { + get(_facetTarget, tableName: string) { + const table = resolveTableInNamespace(storage, namespaceId, tableName); + if (table) { + return new TableProxyImpl(tableName, table, tableName, ctx, namespaceId); + } + return undefined; + }, + }, + ); + } + const resolved = resolveTableForFlatName(storage, prop); if (resolved) { return new TableProxyImpl(prop, resolved.table, prop, ctx, resolved.namespaceId); } diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/table-proxy-impl.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/table-proxy-impl.ts index 6066c320ee..3e57a30862 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/table-proxy-impl.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/table-proxy-impl.ts @@ -77,7 +77,11 @@ export class TableProxyImpl< this.#tableName = tableName; this.#table = table; this.#namespaceId = namespaceId; - this.#scope = tableToScope(alias, table, { storage: ctx.storage, tableName }); + this.#scope = tableToScope(alias, table, { + storage: ctx.storage, + tableName, + namespaceId, + }); this.#fromSource = tableSourceForProxy(tableName, alias, namespaceId); } @@ -174,7 +178,14 @@ export class TableProxyImpl< } insert(rows: ReadonlyArray>): InsertQuery { - return new InsertQueryImpl(this.#fromSource, this.#table, this.#scope, rows, this.ctx); + return new InsertQueryImpl( + this.#fromSource, + this.#namespaceId, + this.#table, + this.#scope, + rows, + this.ctx, + ); } update( @@ -194,6 +205,7 @@ export class TableProxyImpl< ); const setExpressions = buildSetExpressions( callbackExprs, + this.#namespaceId, this.#table, this.#tableName, 'update', @@ -203,6 +215,7 @@ export class TableProxyImpl< } const setExpressions = buildParamValues( setOrCallback, + this.#namespaceId, this.#table, this.#tableName, 'update', diff --git a/packages/2-sql/4-lanes/sql-builder/src/types/db.ts b/packages/2-sql/4-lanes/sql-builder/src/types/db.ts index 755b696f2c..5db99d36b7 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/types/db.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/types/db.ts @@ -38,6 +38,21 @@ export type TableInAnyNamespace.`) when +// the same bare name is declared in more than one namespace. +export type Namespace< + C extends TableProxyContract, + NsId extends keyof C['storage']['namespaces'], +> = { + readonly [Name in keyof C['storage']['namespaces'][NsId]['entries']['table'] & + string]: TableProxy; +}; + +// Additive intersection: the flat by-bare-name surface retained alongside a +// per-namespace facet keyed by namespace id. export type Db = { - [Name in TableNamesAcrossNamespaces]: TableProxy; + readonly [Name in TableNamesAcrossNamespaces]: TableProxy; +} & { + readonly [Ns in keyof C['storage']['namespaces']]: Namespace; }; diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/field-proxy.test.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/field-proxy.test.ts index 0bdfc9f539..c2175a173d 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/runtime/field-proxy.test.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/field-proxy.test.ts @@ -92,7 +92,11 @@ describe('createFieldProxy', () => { }, }, }); - const scope = tableToScope('post_alias', table, { storage, tableName: 'Post' }); + const scope = tableToScope('post_alias', table, { + storage, + namespaceId: UNBOUND_NAMESPACE_ID, + tableName: 'Post', + }); expect(scope.namespaces['post_alias']?.['embedding']?.codec).toEqual({ codecId: 'pgvector/vector@1', typeParams: { length: 1536 }, diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/namespaced-resolution.test.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/namespaced-resolution.test.ts new file mode 100644 index 0000000000..af909c5433 --- /dev/null +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/namespaced-resolution.test.ts @@ -0,0 +1,91 @@ +import type { TableSource } from '@prisma-next/sql-relational-core/ast'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { describe, expect, it } from 'vitest'; +import { sql } from '../../src/runtime/sql'; +import type { Contract } from '../fixtures/generated/contract'; + +const int4 = { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false } as const; +const text = { codecId: 'pg/text@1', nativeType: 'text', nullable: false } as const; + +function table(columns: Record) { + return { + columns, + foreignKeys: [], + indexes: [], + primaryKey: { columns: ['id'] }, + uniques: [], + }; +} + +// Same bare table name (`users`) declared in two namespaces, plus a table +// that exists in only one namespace, so resolution must discriminate by +// namespace coordinate rather than fall back to a cross-namespace scan. +const twoNamespaceContract = { + capabilities: {}, + target: 'postgres', + storage: { + storageHash: 'stub', + namespaces: { + public: { + entries: { table: { users: table({ id: int4, email: text }), posts: table({ id: int4 }) } }, + }, + auth: { + entries: { + table: { users: table({ id: int4, token: text }), sessions: table({ id: int4 }) }, + }, + }, + }, + }, +}; + +const stubBase = { + operations: {}, + codecs: {}, + queryOperations: { entries: () => ({}) }, + types: {}, + applyMutationDefaults: () => [], +}; + +const stubInferer = { inferCodec: () => 'pg/text@1' }; + +type TableHandle = { buildAst(): TableSource }; +type TwoNamespaceDb = { + public: { users: TableHandle; sessions: undefined }; + auth: { users: TableHandle; sessions: TableHandle }; + users: TableHandle; + posts: TableHandle; +}; + +function db() { + return sql({ + context: { + ...stubBase, + contract: twoNamespaceContract, + } as unknown as ExecutionContext, + rawCodecInferer: stubInferer, + }) as unknown as TwoNamespaceDb; +} + +describe('namespaced table resolution', () => { + it('resolves the same bare name to the distinct table in each namespace', () => { + expect(db().public.users.buildAst().namespaceId).toBe('public'); + expect(db().auth.users.buildAst().namespaceId).toBe('auth'); + }); + + it('scopes table lookup to the named namespace rather than scanning across namespaces', () => { + // `sessions` exists only in `auth`; a cross-namespace scan would wrongly + // resolve it under `public`. + expect(db().public.sessions).toBeUndefined(); + expect(db().auth.sessions.buildAst().namespaceId).toBe('auth'); + }); + + it('keeps the flat surface resolving a unique bare table name', () => { + // `posts` is declared only in `public`, so the flat surface resolves it + // unambiguously. + expect(db().posts.buildAst().namespaceId).toBe('public'); + }); + + it('throws on flat access to a bare table name shared across namespaces', () => { + expect(() => db().users).toThrow(/ambiguous/i); + }); +}); diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/same-bare-table-name.test.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/same-bare-table-name.test.ts new file mode 100644 index 0000000000..a68665fce6 --- /dev/null +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/same-bare-table-name.test.ts @@ -0,0 +1,111 @@ +import type { InsertAst, ProjectionItem, SelectAst } from '@prisma-next/sql-relational-core/ast'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { describe, expect, it } from 'vitest'; +import { sql } from '../../src/runtime/sql'; +import type { Contract } from '../fixtures/generated/contract'; + +function column(codecId: string) { + return { codecId, nativeType: codecId, nullable: false } as const; +} + +function table(columns: Record>) { + return { + columns, + foreignKeys: [], + indexes: [], + primaryKey: { columns: ['id'] }, + uniques: [], + }; +} + +// Both namespaces declare a table with the same bare name `users` but with +// differing columns/codecs, so column/codec resolution must discriminate by +// the namespace coordinate the proxy carries. +const twoNamespaceContract = { + capabilities: {}, + target: 'postgres', + storage: { + storageHash: 'stub', + namespaces: { + public: { + id: 'public', + entries: { + table: { users: table({ id: column('pg/int4@1'), email_addr: column('pg/text@1') }) }, + }, + }, + auth: { + id: 'auth', + entries: { + table: { users: table({ id: column('pg/int4@1'), token_col: column('pg/varchar@1') }) }, + }, + }, + }, + }, +}; + +const stubBase = { + operations: {}, + codecs: {}, + queryOperations: { entries: () => ({}) }, + types: {}, + applyMutationDefaults: () => [], +}; + +type TableHandle = { + select(column: string): { build(): { ast: SelectAst } }; + insert(rows: ReadonlyArray>): { build(): { ast: InsertAst } }; +}; +type TwoNamespaceDb = { + public: { users: TableHandle }; + auth: { users: TableHandle }; +}; + +function db() { + return sql({ + context: { + ...stubBase, + contract: twoNamespaceContract, + } as unknown as ExecutionContext, + rawCodecInferer: { inferCodec: () => 'pg/text@1' }, + }) as unknown as TwoNamespaceDb; +} + +function projectionCodecId(ast: SelectAst): string | undefined { + const projection = (ast as unknown as { projection: ProjectionItem[] }).projection[0]; + return (projection as unknown as { codec?: { codecId: string } }).codec?.codecId; +} + +function insertParamCodecId(ast: InsertAst, column: string): string | undefined { + const value = ast.rows[0]?.[column]; + return (value as unknown as { codec?: { codecId: string } } | undefined)?.codec?.codecId; +} + +describe('same bare table name across namespaces', () => { + it('resolves the column codec within the proxy namespace, discriminating per namespace', () => { + const publicAst = db().public.users.select('email_addr').build().ast; + expect(projectionCodecId(publicAst)).toBe('pg/text@1'); + expect((publicAst as unknown as { from: { namespaceId: string } }).from.namespaceId).toBe( + 'public', + ); + + const authAst = db().auth.users.select('token_col').build().ast; + expect(projectionCodecId(authAst)).toBe('pg/varchar@1'); + expect((authAst as unknown as { from: { namespaceId: string } }).from.namespaceId).toBe('auth'); + }); + + it('resolves insert param codecs within the proxy namespace, discriminating per namespace', () => { + const publicInsert = db() + .public.users.insert([{ id: 1, email_addr: 'a@example.com' }]) + .build().ast; + expect(insertParamCodecId(publicInsert, 'email_addr')).toBe('pg/text@1'); + expect(insertParamCodecId(publicInsert, 'id')).toBe('pg/int4@1'); + expect(publicInsert.table.namespaceId).toBe('public'); + + const authInsert = db() + .auth.users.insert([{ id: 2, token_col: 'tok' }]) + .build().ast; + expect(insertParamCodecId(authInsert, 'token_col')).toBe('pg/varchar@1'); + expect(insertParamCodecId(authInsert, 'id')).toBe('pg/int4@1'); + expect(authInsert.table.namespaceId).toBe('auth'); + }); +}); diff --git a/packages/2-sql/4-lanes/sql-builder/test/types/namespaced-db.types.test-d.ts b/packages/2-sql/4-lanes/sql-builder/test/types/namespaced-db.types.test-d.ts new file mode 100644 index 0000000000..a1b2df3ff4 --- /dev/null +++ b/packages/2-sql/4-lanes/sql-builder/test/types/namespaced-db.types.test-d.ts @@ -0,0 +1,21 @@ +import { expectTypeOf, test } from 'vitest'; +import type { Db, Namespace, TableProxy } from '../../src/exports/types'; +import type { Contract } from '../fixtures/generated/contract'; + +declare const db: Db; + +test('the namespace facet exposes its tables as TableProxy', () => { + expectTypeOf(db.public.users).toEqualTypeOf>(); + expectTypeOf['users']>().toEqualTypeOf< + TableProxy + >(); +}); + +test('the flat surface is retained alongside the namespace facet', () => { + expectTypeOf(db.users).toEqualTypeOf>(); +}); + +test('an undeclared namespace id is not a key on the typed surface', () => { + // @ts-expect-error 'auth' is not a declared storage namespace of this contract + db.auth; +}); diff --git a/packages/2-sql/5-runtime/src/sql-context.ts b/packages/2-sql/5-runtime/src/sql-context.ts index e1b908df48..584d5e05ac 100644 --- a/packages/2-sql/5-runtime/src/sql-context.ts +++ b/packages/2-sql/5-runtime/src/sql-context.ts @@ -425,10 +425,10 @@ function assertColumnCodecIntegrity( storage: SqlStorage, codecDescriptors: CodecDescriptorRegistry, ): void { - for (const ns of Object.values(storage.namespaces)) { + for (const [namespaceId, ns] of Object.entries(storage.namespaces)) { for (const [tableName, table] of Object.entries(ns.entries.table)) { for (const columnName of Object.keys(table.columns)) { - const ref = codecDescriptors.codecRefForColumn(tableName, columnName); + const ref = codecDescriptors.codecRefForColumn(namespaceId, tableName, columnName); if (!ref) continue; const descriptor = codecDescriptors.descriptorFor(ref.codecId); @@ -520,7 +520,7 @@ function assertColumnCodecIntegrity( * * Contract integrity is enforced upstream by {@link assertColumnCodecIntegrity}: every column must reference a registered `codecId` whose `descriptor.isParameterized` flag matches the presence of `typeParams` (via `codecRefForColumn`). The pre-population walk and `forColumn` therefore make no defensive checks — malformed columns fail fast at `createExecutionContext` construction with `RUNTIME.CODEC_DESCRIPTOR_MISSING` or `RUNTIME.CODEC_PARAMETERIZATION_MISMATCH` rather than being silently skipped here. * - * `forColumn(t, c)` is a thin delegate over `forCodecRef(codecRefForColumn(t, c))`; encode/decode hot paths read the resolver directly via `forCodecRef`. The only `undefined` `forColumn` returns is the legitimate "no such column in the contract" case. + * `forColumn(ns, t, c)` is a thin delegate over `forCodecRef(codecRefForColumn(ns, t, c))`; encode/decode hot paths read the resolver directly via `forCodecRef`. The only `undefined` `forColumn` returns is the legitimate "no such column in the contract" case. */ function buildContractCodecRegistry( contract: Contract, @@ -564,11 +564,11 @@ function buildContractCodecRegistry( } } - for (const ns of Object.values(contract.storage.namespaces)) { + for (const [namespaceId, ns] of Object.entries(contract.storage.namespaces)) { for (const [tableName, table] of Object.entries(ns.entries.table)) { for (const [columnName, column] of Object.entries(table.columns)) { if (column.typeRef !== undefined) continue; - const ref = codecDescriptors.codecRefForColumn(tableName, columnName); + const ref = codecDescriptors.codecRefForColumn(namespaceId, tableName, columnName); if (!ref) continue; const key = refKeyOf(ref); const site = { table: tableName, column: columnName }; @@ -596,10 +596,10 @@ function buildContractCodecRegistry( }; }); - for (const ns of Object.values(contract.storage.namespaces)) { + for (const [namespaceId, ns] of Object.entries(contract.storage.namespaces)) { for (const [tableName, table] of Object.entries(ns.entries.table)) { for (const columnName of Object.keys(table.columns)) { - const ref = codecDescriptors.codecRefForColumn(tableName, columnName); + const ref = codecDescriptors.codecRefForColumn(namespaceId, tableName, columnName); if (!ref) continue; resolver.forCodecRef(ref); } @@ -607,8 +607,8 @@ function buildContractCodecRegistry( } const registry: ContractCodecRegistry = { - forColumn(table, column) { - const ref = codecDescriptors.codecRefForColumn(table, column); + forColumn(namespaceId, table, column) { + const ref = codecDescriptors.codecRefForColumn(namespaceId, table, column); return ref ? resolver.forCodecRef(ref) : undefined; }, forCodecRef(ref) { diff --git a/packages/2-sql/5-runtime/test/contract-codec-registry.test.ts b/packages/2-sql/5-runtime/test/contract-codec-registry.test.ts index 9a54973cd6..fef888feb3 100644 --- a/packages/2-sql/5-runtime/test/contract-codec-registry.test.ts +++ b/packages/2-sql/5-runtime/test/contract-codec-registry.test.ts @@ -19,7 +19,7 @@ import { createStubAdapter, createTestContext } from './utils'; // The codec-registry layer exposes two runtime registries: // -// - `ContractCodecRegistry` (`context.contractCodecs`): per-column resolved-codec dispatch with `forColumn(table, column)` and content-keyed AST dispatch via `forCodecRef(ref)`. +// - `ContractCodecRegistry` (`context.contractCodecs`): per-column resolved-codec dispatch with `forColumn(namespace, table, column)` and content-keyed AST dispatch via `forCodecRef(ref)`. // - `CodecDescriptorRegistry` (`context.codecDescriptors`): codec-id-keyed metadata read with `descriptorFor(codecId)` — non-branching for parameterized vs. non-parameterized codecs (every non-parameterized codec is auto-lifted into a synthesized `CodecDescriptor`). function makeVectorCodec(meta?: Record): Codec { @@ -167,7 +167,7 @@ describe('ContractCodecRegistry', () => { extensionPacks: [createVectorExtensionDescriptor()], }); - const resolved = context.contractCodecs.forColumn('Doc', 'embedding'); + const resolved = context.contractCodecs.forColumn('__unbound__', 'Doc', 'embedding'); expect(resolved).toBeDefined(); // The per-instance codec carries the column's `length` on its meta — confirms the dispatch path resolves through `factory(typeParams) (ctx)`, not the codec-id-keyed fallback. expect((resolved as Codec & { meta: { length: number } }).meta.length).toBe(768); @@ -207,8 +207,8 @@ describe('ContractCodecRegistry', () => { extensionPacks: [createVectorExtensionDescriptor()], }); - const docCodec = context.contractCodecs.forColumn('Doc', 'embedding'); - const pageCodec = context.contractCodecs.forColumn('Page', 'embedding'); + const docCodec = context.contractCodecs.forColumn('__unbound__', 'Doc', 'embedding'); + const pageCodec = context.contractCodecs.forColumn('__unbound__', 'Page', 'embedding'); expect(docCodec).toBeDefined(); expect(pageCodec).toBeDefined(); @@ -229,8 +229,8 @@ describe('ContractCodecRegistry', () => { extensionPacks: [createNonParameterizedExtensionDescriptor()], }); - const primaryCodec = context.contractCodecs.forColumn('User', 'primary'); - const secondaryCodec = context.contractCodecs.forColumn('User', 'secondary'); + const primaryCodec = context.contractCodecs.forColumn('__unbound__', 'User', 'primary'); + const secondaryCodec = context.contractCodecs.forColumn('__unbound__', 'User', 'secondary'); expect(primaryCodec).toBeDefined(); expect(secondaryCodec).toBeDefined(); @@ -250,8 +250,10 @@ describe('ContractCodecRegistry', () => { extensionPacks: [createNonParameterizedExtensionDescriptor()], }); - expect(context.contractCodecs.forColumn('User', 'nonexistent')).toBeUndefined(); - expect(context.contractCodecs.forColumn('NoSuchTable', 'whatever')).toBeUndefined(); + expect(context.contractCodecs.forColumn('__unbound__', 'User', 'nonexistent')).toBeUndefined(); + expect( + context.contractCodecs.forColumn('__unbound__', 'NoSuchTable', 'whatever'), + ).toBeUndefined(); }); }); diff --git a/packages/2-sql/5-runtime/test/same-bare-table-name.test.ts b/packages/2-sql/5-runtime/test/same-bare-table-name.test.ts new file mode 100644 index 0000000000..194d7eba55 --- /dev/null +++ b/packages/2-sql/5-runtime/test/same-bare-table-name.test.ts @@ -0,0 +1,84 @@ +import type { Contract } from '@prisma-next/contract/types'; +import { coreHash, profileHash } from '@prisma-next/contract/types'; +import { + buildSqlNamespace, + SqlStorage, + type StorageTableInput, +} from '@prisma-next/sql-contract/types'; +import { applicationDomainOf } from '@prisma-next/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createStubAdapter, createTestContext } from './utils'; + +function table(columns: Record): StorageTableInput { + return { + columns: Object.fromEntries( + Object.entries(columns).map(([name, codecId]) => [ + name, + { nativeType: codecId, codecId, nullable: false }, + ]), + ), + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; +} + +// Both namespaces declare a table with the same bare name `users` but with +// differing columns/codecs. The execution-context codec registry pre-walks every +// table across every namespace, so the two same-bare-named tables must not +// collide — resolution discriminates by the namespace coordinate. +function twoNamespaceContract(): Contract { + return { + targetFamily: 'sql', + target: 'postgres', + profileHash: profileHash('sha256:test'), + domain: applicationDomainOf({ models: {} }), + roots: {}, + storage: new SqlStorage({ + storageHash: coreHash('sha256:test'), + namespaces: { + public: buildSqlNamespace({ + id: 'public', + entries: { table: { users: table({ id: 'pg/int4@1', email: 'pg/text@1' }) } }, + }), + auth: buildSqlNamespace({ + id: 'auth', + entries: { table: { users: table({ id: 'pg/int4@1', token: 'sql/varchar@1' }) } }, + }), + }, + }), + extensionPacks: {}, + capabilities: {}, + meta: {}, + }; +} + +describe('same bare table name across namespaces — execution context', () => { + it('loads through createExecutionContext without throwing an ambiguity error', () => { + expect(() => createTestContext(twoNamespaceContract(), createStubAdapter())).not.toThrow(); + }); + + it('resolves the per-namespace column codec via the coordinate, discriminating per namespace', () => { + const context = createTestContext(twoNamespaceContract(), createStubAdapter()); + + expect(context.contractCodecs.forColumn('public', 'users', 'email')?.id).toBe('pg/text@1'); + expect(context.contractCodecs.forColumn('auth', 'users', 'token')?.id).toBe('sql/varchar@1'); + + // A column present only in the other namespace must not resolve here — proves + // resolution honours the coordinate rather than first-matching by bare name. + expect(context.contractCodecs.forColumn('public', 'users', 'token')).toBeUndefined(); + expect(context.contractCodecs.forColumn('auth', 'users', 'email')).toBeUndefined(); + }); + + it('exposes the per-namespace codec ref through the descriptor registry coordinate', () => { + const context = createTestContext(twoNamespaceContract(), createStubAdapter()); + + expect(context.codecDescriptors.codecRefForColumn('public', 'users', 'email')).toEqual({ + codecId: 'pg/text@1', + }); + expect(context.codecDescriptors.codecRefForColumn('auth', 'users', 'token')).toEqual({ + codecId: 'sql/varchar@1', + }); + }); +}); diff --git a/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts b/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts index f5c9e9f513..a5554d0556 100644 --- a/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts +++ b/packages/2-sql/5-runtime/test/sql-context.codec-context.test.ts @@ -106,7 +106,7 @@ describe('buildContractCodecRegistry — per-column codec instance context', () extensionPacks: [descriptor], }); - const columnInstance = context.contractCodecs.forColumn('users', 'field'); + const columnInstance = context.contractCodecs.forColumn('__unbound__', 'users', 'field'); expect(columnInstance).toBeDefined(); const columnCtx = instances.find(({ codec }) => codec === columnInstance)?.ctx; @@ -328,8 +328,8 @@ describe('buildContractCodecRegistry — forCodecRef content-keyed cache', () => expect((codec as Codec & { meta: { ctxName: string } }).meta.ctxName).toBe('V1536'); // The shared-per-codec invariant: forColumn lookups on either typeRef-sharing column resolve to the very same instance returned by forCodecRef. A regression that re-materialised per-column instances would still pass the existence/name asserts above; identity is what guards against that. - const fromDoc = context.contractCodecs.forColumn('Doc', 'embedding'); - const fromPage = context.contractCodecs.forColumn('Page', 'embedding'); + const fromDoc = context.contractCodecs.forColumn('__unbound__', 'Doc', 'embedding'); + const fromPage = context.contractCodecs.forColumn('__unbound__', 'Page', 'embedding'); expect(fromDoc).toBe(codec); expect(fromPage).toBe(codec); }); @@ -353,8 +353,8 @@ describe('buildContractCodecRegistry — forCodecRef content-keyed cache', () => expect(codec).toBeDefined(); // forColumn on both columns should reach the same instance. - expect(context.contractCodecs.forColumn('Doc', 'embedding')).toBe(codec); - expect(context.contractCodecs.forColumn('Page', 'embedding')).toBe(codec); + expect(context.contractCodecs.forColumn('__unbound__', 'Doc', 'embedding')).toBe(codec); + expect(context.contractCodecs.forColumn('__unbound__', 'Page', 'embedding')).toBe(codec); }); it('throws RUNTIME.CODEC_DESCRIPTOR_MISSING when the codecId is unknown to the resolver', () => { @@ -452,7 +452,7 @@ describe('buildContractCodecRegistry — forColumn delegates to forCodecRef', () }; } - it('forColumn(t, c) and forCodecRef(codecRefForColumn(t, c)) return the same codec instance', () => { + it('forColumn(ns, t, c) and forCodecRef(codecRefForColumn(ns, t, c)) return the same codec instance', () => { const { descriptor } = createSharedCodecExtension(); const contract = contractWith({ users: { codecId: 'test/shared@1', nativeType: 'shared' }, @@ -462,8 +462,8 @@ describe('buildContractCodecRegistry — forColumn delegates to forCodecRef', () extensionPacks: [descriptor], }); - const fromColumn = context.contractCodecs.forColumn('users', 'field'); - const ref = context.codecDescriptors.codecRefForColumn('users', 'field'); + const fromColumn = context.contractCodecs.forColumn('__unbound__', 'users', 'field'); + const ref = context.codecDescriptors.codecRefForColumn('__unbound__', 'users', 'field'); expect(ref).toBeDefined(); const fromRef = context.contractCodecs.forCodecRef(ref!); @@ -482,8 +482,8 @@ describe('buildContractCodecRegistry — forColumn delegates to forCodecRef', () extensionPacks: [descriptor], }); - const usersInstance = context.contractCodecs.forColumn('users', 'field'); - const ordersInstance = context.contractCodecs.forColumn('orders', 'field'); + const usersInstance = context.contractCodecs.forColumn('__unbound__', 'users', 'field'); + const ordersInstance = context.contractCodecs.forColumn('__unbound__', 'orders', 'field'); expect(usersInstance).toBeDefined(); expect(ordersInstance).toBe(usersInstance); diff --git a/packages/3-extensions/postgres/test/fixtures/namespaced-contract.ts b/packages/3-extensions/postgres/test/fixtures/namespaced-contract.ts new file mode 100644 index 0000000000..0afab0b812 --- /dev/null +++ b/packages/3-extensions/postgres/test/fixtures/namespaced-contract.ts @@ -0,0 +1,77 @@ +import type { Contract as ContractType, StorageHashBase } from '@prisma-next/contract/types'; +import type { ContractWithTypeMaps, TypeMaps } from '@prisma-next/sql-contract/types'; + +// A hand-authored stand-in for an emitted `contract.d.ts`, trimmed to the +// shape the facade reachability tests need: a single `public` namespace whose +// storage carries a `users` table and whose domain carries a `User` model. +// Mirrors the structural literal an emitted contract produces (so it stays +// assignable to the facade's `Contract` bound) without depending +// on a target's generated codec type maps. + +type Models = { + readonly User: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'users'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly name: { readonly column: 'name' }; + }; + }; + }; +}; + +type Storage = { + readonly storageHash: StorageHashBase<'sha256:namespaced-facade-fixture'>; + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'sql-namespace'; + readonly entries: { + readonly table: { + readonly users: { + columns: { + readonly id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly name: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; +}; + +type ContractBase = Omit, 'roots' | 'domain'> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: Record; + readonly domain: { + readonly namespaces: { + readonly public: { readonly models: Models }; + }; + }; +}; + +export type Contract = ContractWithTypeMaps; diff --git a/packages/3-extensions/postgres/test/namespaced-facade.types.test-d.ts b/packages/3-extensions/postgres/test/namespaced-facade.types.test-d.ts new file mode 100644 index 0000000000..a539cec646 --- /dev/null +++ b/packages/3-extensions/postgres/test/namespaced-facade.types.test-d.ts @@ -0,0 +1,50 @@ +import type { Db, Namespace, TableProxy } from '@prisma-next/sql-builder/types'; +import { expectTypeOf, test } from 'vitest'; +import type { PostgresClient, PostgresTransactionContext } from '../src/runtime/postgres'; +import type { Contract } from './fixtures/namespaced-contract'; + +declare const db: PostgresClient; + +type DbSql = PostgresClient['sql']; +type DbOrm = PostgresClient['orm']; + +test('db.sql exposes the namespace facet alongside the flat surface', () => { + expectTypeOf(db.sql.public.users).toEqualTypeOf>(); + expectTypeOf(db.sql.users).toEqualTypeOf>(); + expectTypeOf['users']>().toEqualTypeOf< + TableProxy + >(); +}); + +test('db.orm exposes the namespace facet alongside the flat surface', () => { + expectTypeOf(db.orm.public.User).toEqualTypeOf(db.orm.User); + expectTypeOf(db.orm.User).toEqualTypeOf(db.orm.public.User); +}); + +test('an undeclared namespace id is not a key on db.sql or db.orm', () => { + // @ts-expect-error 'auth' is not a declared storage namespace of this contract + db.sql.auth; + // @ts-expect-error 'auth' is not a declared domain namespace of this contract + db.orm.auth; +}); + +test('transaction re-types sql/orm with the same namespaced surface', () => { + type TxSql = PostgresTransactionContext['sql']; + type TxOrm = PostgresTransactionContext['orm']; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + + db.transaction(async (tx) => { + expectTypeOf(tx.sql.public.users).toEqualTypeOf>(); + expectTypeOf(tx.sql.users).toEqualTypeOf>(); + expectTypeOf(tx.orm.public.User).toEqualTypeOf(tx.orm.User); + return undefined; + }); +}); + +test('prepare callback receives the namespaced sql surface', () => { + type PrepareSql = Parameters['prepare']>[1]>[0]; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); +}); diff --git a/packages/3-extensions/postgres/test/postgres.test.ts b/packages/3-extensions/postgres/test/postgres.test.ts index 3b34ce9ca3..874eae3134 100644 --- a/packages/3-extensions/postgres/test/postgres.test.ts +++ b/packages/3-extensions/postgres/test/postgres.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ createSqlExecutionStack: vi.fn(), withTransaction: vi.fn(), sqlBuilder: vi.fn(), + orm: vi.fn(), driverCreate: vi.fn(), driverConnect: vi.fn(), deserializeContract: vi.fn(), @@ -32,7 +33,7 @@ vi.mock('@prisma-next/sql-builder/runtime', () => ({ })); vi.mock('@prisma-next/sql-orm-client', () => ({ - orm: vi.fn(() => ({ lane: 'orm' })), + orm: mocks.orm, })); vi.mock('@prisma-next/target-postgres/runtime', () => ({ @@ -81,6 +82,7 @@ describe('postgres', () => { mocks.deserializeContract.mockReset(); mocks.poolCtor.mockReset(); mocks.sqlBuilder.mockReset(); + mocks.orm.mockReset(); mocks.createExecutionContext.mockReturnValue({ contract, @@ -104,6 +106,7 @@ describe('postgres', () => { mocks.createRuntime.mockReturnValue({ id: 'runtime-instance' }); mocks.deserializeContract.mockReturnValue(contract); mocks.sqlBuilder.mockReturnValue({ lane: 'sql' }); + mocks.orm.mockReturnValue({ lane: 'orm' }); mocks.withTransaction.mockImplementation( async (_runtime: unknown, fn: (ctx: unknown) => unknown) => { const mockTxCtx = { @@ -478,14 +481,13 @@ describe('postgres', () => { }); it('transaction() provides orm on the transaction context', async () => { - const { orm: ormMock } = await import('@prisma-next/sql-orm-client'); const txOrmProxy = { lane: 'tx-orm' }; let ormCallCount = 0; - vi.mocked(ormMock).mockImplementation((() => { + mocks.orm.mockImplementation(() => { ormCallCount++; if (ormCallCount === 1) return { lane: 'orm' }; return txOrmProxy; - }) as typeof ormMock); + }); const db = postgres({ contract, diff --git a/packages/3-extensions/sql-orm-client/src/aggregate-builder.ts b/packages/3-extensions/sql-orm-client/src/aggregate-builder.ts index 928b651167..28845a967b 100644 --- a/packages/3-extensions/sql-orm-client/src/aggregate-builder.ts +++ b/packages/3-extensions/sql-orm-client/src/aggregate-builder.ts @@ -6,8 +6,12 @@ import type { AggregateBuilder, AggregateSelector, NumericFieldNames } from './t export function createAggregateBuilder< TContract extends Contract, ModelName extends string, ->(contract: TContract, modelName: ModelName): AggregateBuilder { - const fieldToColumn = getFieldToColumnMap(contract, modelName); +>( + contract: TContract, + namespaceId: string, + modelName: ModelName, +): AggregateBuilder { + const fieldToColumn = getFieldToColumnMap(contract, namespaceId, modelName); return { count() { diff --git a/packages/3-extensions/sql-orm-client/src/collection-column-mapping.ts b/packages/3-extensions/sql-orm-client/src/collection-column-mapping.ts index b241ed4c85..f877e64dac 100644 --- a/packages/3-extensions/sql-orm-client/src/collection-column-mapping.ts +++ b/packages/3-extensions/sql-orm-client/src/collection-column-mapping.ts @@ -4,19 +4,21 @@ import { getFieldToColumnMap } from './collection-contract'; export function mapFieldsToColumns( contract: Contract, + namespaceId: string, modelName: string, fieldNames: readonly string[], ): string[] { - const fieldToColumn = getFieldToColumnMap(contract, modelName); + const fieldToColumn = getFieldToColumnMap(contract, namespaceId, modelName); return fieldNames.map((fieldName) => fieldToColumn[fieldName] ?? fieldName); } export function mapCursorValuesToColumns( contract: Contract, + namespaceId: string, modelName: string, cursorValues: Readonly>, ): Record { - const fieldToColumn = getFieldToColumnMap(contract, modelName); + const fieldToColumn = getFieldToColumnMap(contract, namespaceId, modelName); const mappedCursor: Record = {}; for (const [fieldName, value] of Object.entries(cursorValues)) { diff --git a/packages/3-extensions/sql-orm-client/src/collection-contract.ts b/packages/3-extensions/sql-orm-client/src/collection-contract.ts index 93909cd3d3..d1d03f6f3e 100644 --- a/packages/3-extensions/sql-orm-client/src/collection-contract.ts +++ b/packages/3-extensions/sql-orm-client/src/collection-contract.ts @@ -1,13 +1,13 @@ -import { - type Contract, - type ContractFieldType, - type ContractRelationThrough, - type CrossReference, - domainModelsAtDefaultNamespace, +import type { + Contract, + ContractFieldType, + ContractRelationThrough, + CrossReference, } from '@prisma-next/contract/types'; import type { SqlStorage, StorageTable } from '@prisma-next/sql-contract/types'; +import { blindCast } from '@prisma-next/utils/casts'; import { - resolveDomainModelForContract, + domainModelTableInNamespace, resolveTableForContract, storageTableForContract, } from './storage-resolution'; @@ -40,13 +40,33 @@ export interface PolymorphismInfo { readonly mtiVariants: readonly PolymorphismVariantInfo[]; } -function modelsOf(contract: Contract): ModelsMap { - return domainModelsAtDefaultNamespace(contract.domain) as ModelsMap; +// Model map for a model's metadata resolution. The lookup is always scoped to +// an explicit namespace coordinate (`orm..`); bare-name access +// resolves the sole namespace upstream (in the ORM factory) before reaching +// here. +function modelsOf(contract: Contract, namespaceId: string): ModelsMap { + const namespace = contract.domain.namespaces[namespaceId]; + if (namespace === undefined) { + throw new Error(`domain namespace "${namespaceId}" is not present on the contract`); + } + return blindCast( + namespace.models, + ); } -export function modelOf(contract: Contract, name: string): ModelEntry | undefined { - const resolved = resolveDomainModelForContract(contract, name); - return resolved?.model as ModelEntry | undefined; +function metadataCacheKey(namespaceId: string, modelName: string): string { + return `${namespaceId}\u0000${modelName}`; +} + +export function modelOf( + contract: Contract, + namespaceId: string, + name: string, +): ModelEntry | undefined { + const model = contract.domain.namespaces[namespaceId]?.models[name]; + return model === undefined + ? undefined + : blindCast(model); } const fieldToColumnCache = new WeakMap>>(); @@ -55,6 +75,7 @@ const polymorphismCache = new WeakMap, + namespaceId: string, modelName: string, ): PolymorphismInfo | undefined { let perContract = polymorphismCache.get(contract); @@ -62,23 +83,29 @@ export function resolvePolymorphismInfo( perContract = new Map(); polymorphismCache.set(contract, perContract); } - if (perContract.has(modelName)) return perContract.get(modelName); + const cacheKey = metadataCacheKey(namespaceId, modelName); + if (perContract.has(cacheKey)) return perContract.get(cacheKey); - const models = modelsOf(contract); + const models = modelsOf(contract, namespaceId); const model = models[modelName]; if (!model?.discriminator || !model.variants) { - perContract.set(modelName, undefined); + perContract.set(cacheKey, undefined); return undefined; } const baseTable = model.storage?.table; if (!baseTable) { - perContract.set(modelName, undefined); + perContract.set(cacheKey, undefined); return undefined; } const discriminatorField = model.discriminator.field; - const discriminatorColumn = resolveFieldToColumn(contract, modelName, discriminatorField); + const discriminatorColumn = resolveFieldToColumn( + contract, + namespaceId, + modelName, + discriminatorField, + ); const variants = new Map(); const variantsByValue = new Map(); @@ -117,16 +144,17 @@ export function resolvePolymorphismInfo( mtiVariants, }; - perContract.set(modelName, result); + perContract.set(cacheKey, result); return result; } export function resolveFieldToColumn( contract: Contract, + namespaceId: string, modelName: string, fieldName: string, ): string { - return getFieldToColumnMap(contract, modelName)[fieldName] ?? fieldName; + return getFieldToColumnMap(contract, namespaceId, modelName)[fieldName] ?? fieldName; } export interface VariantColumnRef { @@ -154,15 +182,16 @@ export interface VariantColumnRef { */ export function resolveVariantFieldColumns( contract: Contract, + namespaceId: string, baseModelName: string, variantName: string, ): Record { - const polyInfo = resolvePolymorphismInfo(contract, baseModelName); + const polyInfo = resolvePolymorphismInfo(contract, namespaceId, baseModelName); const variant = polyInfo?.variants.get(variantName); const result: Record = {}; if (variant && variant.strategy === 'mti') { - const variantFieldToColumn = getFieldToColumnMap(contract, variant.modelName); + const variantFieldToColumn = getFieldToColumnMap(contract, namespaceId, variant.modelName); for (const [field, column] of Object.entries(variantFieldToColumn)) { result[field] = { table: variant.table, column }; } @@ -173,6 +202,7 @@ export function resolveVariantFieldColumns( export function getFieldToColumnMap( contract: Contract, + namespaceId: string, modelName: string, ): Record { let perContract = fieldToColumnCache.get(contract); @@ -180,20 +210,22 @@ export function getFieldToColumnMap( perContract = new Map(); fieldToColumnCache.set(contract, perContract); } - let cached = perContract.get(modelName); + const cacheKey = metadataCacheKey(namespaceId, modelName); + let cached = perContract.get(cacheKey); if (cached) return cached; - const storageFields = modelsOf(contract)[modelName]?.storage?.fields ?? {}; + const storageFields = modelsOf(contract, namespaceId)[modelName]?.storage?.fields ?? {}; cached = {}; for (const [f, s] of Object.entries(storageFields)) { if (s?.column) cached[f] = s.column; } - perContract.set(modelName, cached); + perContract.set(cacheKey, cached); return cached; } export function getColumnToFieldMap( contract: Contract, + namespaceId: string, modelName: string, ): Record { let perContract = columnToFieldCache.get(contract); @@ -201,15 +233,16 @@ export function getColumnToFieldMap( perContract = new Map(); columnToFieldCache.set(contract, perContract); } - let cached = perContract.get(modelName); + const cacheKey = metadataCacheKey(namespaceId, modelName); + let cached = perContract.get(cacheKey); if (cached) return cached; - const storageFields = modelsOf(contract)[modelName]?.storage?.fields ?? {}; + const storageFields = modelsOf(contract, namespaceId)[modelName]?.storage?.fields ?? {}; cached = {}; for (const [f, s] of Object.entries(storageFields)) { if (s?.column) cached[s.column] = f; } - perContract.set(modelName, cached); + perContract.set(cacheKey, cached); return cached; } @@ -221,6 +254,7 @@ const completeColumnToFieldCache = new WeakMap, + namespaceId: string, modelName: string, ): Record { let perContract = completeColumnToFieldCache.get(contract); @@ -228,15 +262,16 @@ export function getCompleteColumnToFieldMap( perContract = new Map(); completeColumnToFieldCache.set(contract, perContract); } - let cached = perContract.get(modelName); + const cacheKey = metadataCacheKey(namespaceId, modelName); + let cached = perContract.get(cacheKey); if (cached) return cached; - const storageFields = modelsOf(contract)[modelName]?.storage?.fields ?? {}; + const storageFields = modelsOf(contract, namespaceId)[modelName]?.storage?.fields ?? {}; cached = {}; for (const [f, s] of Object.entries(storageFields)) { cached[s?.column ?? f] = f; } - perContract.set(modelName, cached); + perContract.set(cacheKey, cached); return cached; } @@ -246,6 +281,7 @@ interface ResolvedThrough extends ContractRelationThrough { interface ResolvedRelation { readonly to: string; + readonly toNamespace: string; readonly cardinality: RelationCardinalityTag | undefined; readonly on: { readonly localFields: readonly string[]; @@ -256,6 +292,7 @@ interface ResolvedRelation { export interface ResolvedIncludeRelation { readonly relatedModelName: string; + readonly relatedNamespaceId: string; readonly relatedTableName: string; readonly targetColumn: string; readonly localColumn: string; @@ -264,10 +301,11 @@ export interface ResolvedIncludeRelation { export function resolveIncludeRelation( contract: Contract, + namespaceId: string, modelName: string, relationName: string, ): ResolvedIncludeRelation { - const relations = resolveModelRelations(contract, modelName); + const relations = resolveModelRelations(contract, namespaceId, modelName); const relation = relations[relationName]; if (!relation) { throw new Error(`Relation '${relationName}' not found on model '${modelName}'`); @@ -280,12 +318,18 @@ export function resolveIncludeRelation( ); } - const relatedTableName = resolveModelTableName(contract, relation.to); - const localColumn = resolveFieldToColumn(contract, modelName, localField); - const targetColumn = resolveFieldToColumn(contract, relation.to, targetField); + const relatedTableName = resolveModelTableName(contract, relation.toNamespace, relation.to); + const localColumn = resolveFieldToColumn(contract, namespaceId, modelName, localField); + const targetColumn = resolveFieldToColumn( + contract, + relation.toNamespace, + relation.to, + targetField, + ); return { relatedModelName: relation.to, + relatedNamespaceId: relation.toNamespace, relatedTableName, targetColumn, localColumn, @@ -325,6 +369,7 @@ const modelRelationsCache = new WeakMap, + namespaceId: string, modelName: string, ): Record { let perContract = modelRelationsCache.get(contract); @@ -332,10 +377,11 @@ export function resolveModelRelations( perContract = new Map(); modelRelationsCache.set(contract, perContract); } - const cached = perContract.get(modelName); + const cacheKey = metadataCacheKey(namespaceId, modelName); + const cached = perContract.get(cacheKey); if (cached) return cached; - const models = modelsOf(contract); + const models = modelsOf(contract, namespaceId); const relationMap = models[modelName]?.relations ?? {}; const resolved: Record = {}; @@ -365,6 +411,7 @@ export function resolveModelRelations( resolved[name] = { to: rel.to.model, + toNamespace: rel.to.namespace, cardinality: parseRelationCardinality(rel.cardinality), on: { localFields: localFields as readonly string[], @@ -374,7 +421,7 @@ export function resolveModelRelations( }; } - perContract.set(modelName, resolved); + perContract.set(cacheKey, resolved); return resolved; } @@ -387,48 +434,64 @@ export function parseRelationCardinality(value: unknown): RelationCardinalityTag export function resolveUpsertConflictColumns( contract: Contract, + namespaceId: string, modelName: string, conflictOn: Record | undefined, ): string[] { if (conflictOn && typeof conflictOn === 'object') { const columns = Object.keys(conflictOn).map((fieldName) => - resolveFieldToColumn(contract, modelName, fieldName), + resolveFieldToColumn(contract, namespaceId, modelName, fieldName), ); if (columns.length > 0) { return columns; } } - const tableName = resolveModelTableName(contract, modelName); - const primaryKeyColumns = storageTableForContract(contract, tableName).primaryKey?.columns ?? []; + const tableName = resolveModelTableName(contract, namespaceId, modelName); + const primaryKeyColumns = + storageTableForContract(contract, namespaceId, tableName).primaryKey?.columns ?? []; return [...primaryKeyColumns]; } -export function resolveModelTableName(contract: Contract, modelName: string): string { - const resolved = resolveDomainModelForContract(contract, modelName); - if (!resolved) { - throw new Error(`Model "${modelName}" not found in contract`); - } - const model = resolved.model as ModelEntry; - if (model.storage && typeof model.storage.table === 'string') { - return model.storage.table; +export function resolveModelTableName( + contract: Contract, + namespaceId: string, + modelName: string, +): string { + const table = domainModelTableInNamespace(contract, namespaceId, modelName); + if (table === undefined) { + throw new Error( + `Model "${modelName}" has invalid or missing storage.table in namespace "${namespaceId}"`, + ); } - throw new Error(`Model "${modelName}" has invalid or missing storage.table in the contract`); + return table; } -export function resolvePrimaryKeyColumn(contract: Contract, tableName: string): string { - const resolved = resolveTableForContract(contract, tableName); +export function resolvePrimaryKeyColumn( + contract: Contract, + namespaceId: string, + tableName: string, +): string { + const resolved = resolveTableForContract(contract, namespaceId, tableName); return resolved?.table.primaryKey?.columns[0] ?? 'id'; } export function resolveRowIdentityColumns( contract: Contract, + namespaceId: string, tableName: string, ): readonly string[] { let table: StorageTable; try { - table = storageTableForContract(contract, tableName); - } catch { + table = storageTableForContract(contract, namespaceId, tableName); + } catch (error) { + // An ambiguous bare name is a real diagnostic the caller must see — never + // mask it as "table has no identity columns" (which surfaces as a + // misleading "no primary key" error). A genuinely unknown table stays + // lenient and resolves to no identity columns. + if (error instanceof Error && error.message.includes('ambiguous')) { + throw error; + } return []; } if (table.primaryKey && table.primaryKey.columns.length > 0) { diff --git a/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts b/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts index 1135dbde02..ba09b2d397 100644 --- a/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts +++ b/packages/3-extensions/sql-orm-client/src/collection-dispatch.ts @@ -59,18 +59,26 @@ export function dispatchCollectionRows(options: { state: CollectionState; tableName: string; modelName: string; + namespaceId: string; }): AsyncIterableResult { - const { contract, runtime, state, tableName, modelName } = options; - const polyInfo = resolvePolymorphismInfo(contract, modelName); + const { contract, runtime, state, tableName, modelName, namespaceId } = options; + const polyInfo = resolvePolymorphismInfo(contract, namespaceId, modelName); if (state.includes.length === 0) { - const compiled = compileSelect(contract, tableName, state, modelName); + const compiled = compileSelect(contract, namespaceId, tableName, state, modelName); const source = executeQueryPlan>(runtime, compiled); const mapper = polyInfo ? (rawRow: Record) => - mapPolymorphicRow(contract, modelName, polyInfo, rawRow, state.variantName) as Row + mapPolymorphicRow( + contract, + namespaceId, + modelName, + polyInfo, + rawRow, + state.variantName, + ) as Row : (rawRow: Record) => - mapStorageRowToModelFields(contract, modelName, rawRow) as Row; + mapStorageRowToModelFields(contract, namespaceId, modelName, rawRow) as Row; return mapResultRows(source, mapper); } @@ -86,8 +94,9 @@ function dispatchWithIncludes(options: { state: CollectionState; tableName: string; modelName: string; + namespaceId: string; }): AsyncIterableResult { - const { contract, runtime, state, tableName, modelName } = options; + const { contract, runtime, state, tableName, modelName, namespaceId } = options; const generator = async function* (): AsyncGenerator { const { scope, release } = await acquireRuntimeScope(runtime); try { @@ -96,6 +105,7 @@ function dispatchWithIncludes(options: { augmentSelectionForJoinColumns(state.selectedFields, parentJoinColumns); const compiled = compileSelectWithIncludes( contract, + namespaceId, tableName, { ...state, @@ -112,11 +122,11 @@ function dispatchWithIncludes(options: { return; } - const polyInfo = resolvePolymorphismInfo(contract, modelName); + const polyInfo = resolvePolymorphismInfo(contract, namespaceId, modelName); const parentRows = parentRowsRaw.map((row) => { const mapped = polyInfo - ? mapPolymorphicRow(contract, modelName, polyInfo, row, state.variantName) - : mapStorageRowToModelFields(contract, modelName, row); + ? mapPolymorphicRow(contract, namespaceId, modelName, polyInfo, row, state.variantName) + : mapStorageRowToModelFields(contract, namespaceId, modelName, row); return { raw: row, mapped } as RowEnvelope; }); @@ -130,7 +140,13 @@ function dispatchWithIncludes(options: { } if (hiddenParentColumns.length > 0) { - stripHiddenMappedFields(contract, modelName, parent.mapped, hiddenParentColumns); + stripHiddenMappedFields( + contract, + namespaceId, + modelName, + parent.mapped, + hiddenParentColumns, + ); } } @@ -170,24 +186,39 @@ export function reloadMutationRowsByIdentities(options: { runtime: CollectionContext>['runtime']; tableName: string; modelName: string; + namespaceId: string; identityRows: readonly Record[]; selectedFields: readonly string[] | undefined; includes: readonly IncludeExpr[]; }): AsyncIterableResult { - const { contract, runtime, tableName, modelName, identityRows, selectedFields, includes } = - options; + const { + contract, + runtime, + tableName, + modelName, + namespaceId, + identityRows, + selectedFields, + includes, + } = options; if (identityRows.length === 0) { return emptyResult(); } - const identityColumns = resolveRowIdentityColumns(contract, tableName); + const identityColumns = resolveRowIdentityColumns(contract, namespaceId, tableName); if (identityColumns.length === 0) { throw new Error( `Cannot load includes for the mutation result on model "${modelName}": table "${tableName}" has no primary key or unique constraint to key the include read-back on.`, ); } - const identityFilter = buildIdentityInFilter(contract, tableName, identityColumns, identityRows); + const identityFilter = buildIdentityInFilter( + contract, + namespaceId, + tableName, + identityColumns, + identityRows, + ); if (!identityFilter) { return emptyResult(); } @@ -203,6 +234,7 @@ export function reloadMutationRowsByIdentities(options: { }, tableName, modelName, + namespaceId, }); } @@ -215,6 +247,7 @@ function emptyResult(): AsyncIterableResult { // the `IN` list (or the composite-key `OR` of equality tuples) directly. function buildIdentityInFilter( contract: Contract, + namespaceId: string, tableName: string, identityColumns: readonly string[], identityRows: readonly Record[], @@ -230,6 +263,7 @@ function buildIdentityInFilter( return bindWhereExpr( contract, BinaryExpr.in(ColumnRef.of(tableName, singleColumn), ListExpression.fromValues(values)), + namespaceId, ); } @@ -243,7 +277,7 @@ function buildIdentityInFilter( ), ), ); - return bindWhereExpr(contract, OrExpr.of(tuples)); + return bindWhereExpr(contract, OrExpr.of(tuples), namespaceId); } /** @@ -281,18 +315,28 @@ function decodeIncludePayload( return decodeCombineIncludePayload(contract, include, include.combine, raw); } const rawChildren = parseIncludedRows(raw); - const polyInfo = resolvePolymorphismInfo(contract, include.relatedModelName); + const polyInfo = resolvePolymorphismInfo( + contract, + include.relatedNamespaceId, + include.relatedModelName, + ); const mapChildRow = polyInfo ? (childRow: Record) => mapPolymorphicRow( contract, + include.relatedNamespaceId, include.relatedModelName, polyInfo, childRow, include.nested.variantName, ) : (childRow: Record) => - mapStorageRowToModelFields(contract, include.relatedModelName, childRow); + mapStorageRowToModelFields( + contract, + include.relatedNamespaceId, + include.relatedModelName, + childRow, + ); const mappedChildren = rawChildren.map((childRow) => { const mapped = mapChildRow(childRow); // Source each nested-include payload from the RAW child row: it always diff --git a/packages/3-extensions/sql-orm-client/src/collection-internal-types.ts b/packages/3-extensions/sql-orm-client/src/collection-internal-types.ts index e92bc4faa1..b6d8517539 100644 --- a/packages/3-extensions/sql-orm-client/src/collection-internal-types.ts +++ b/packages/3-extensions/sql-orm-client/src/collection-internal-types.ts @@ -18,6 +18,7 @@ import type { export interface CollectionInit> { readonly tableName?: string | undefined; + readonly namespaceId: string; readonly state?: import('./types').CollectionState | undefined; readonly registry?: ReadonlyMap> | undefined; readonly includeRefinementMode?: boolean | undefined; diff --git a/packages/3-extensions/sql-orm-client/src/collection-mutation-dispatch.ts b/packages/3-extensions/sql-orm-client/src/collection-mutation-dispatch.ts index 5911f93d7a..39fc6810db 100644 --- a/packages/3-extensions/sql-orm-client/src/collection-mutation-dispatch.ts +++ b/packages/3-extensions/sql-orm-client/src/collection-mutation-dispatch.ts @@ -17,6 +17,7 @@ interface DispatchMutationRowsOptions { readonly compiled: SqlQueryPlan>; readonly tableName: string; readonly modelName: string; + readonly namespaceId: string; readonly includes: readonly IncludeExpr[]; readonly selectedFields: readonly string[] | undefined; readonly hiddenColumns: readonly string[]; @@ -32,6 +33,7 @@ export function dispatchMutationRows( compiled, tableName, modelName, + namespaceId, includes, selectedFields, hiddenColumns, @@ -42,9 +44,9 @@ export function dispatchMutationRows( const source = executeQueryPlan>(runtime, compiled); return mapResultRows(source, (rawRow) => { - const mapped = mapStorageRowToModelFields(contract, modelName, rawRow); + const mapped = mapStorageRowToModelFields(contract, namespaceId, modelName, rawRow); if (hiddenColumns.length > 0) { - stripHiddenMappedFields(contract, modelName, mapped, hiddenColumns); + stripHiddenMappedFields(contract, namespaceId, modelName, mapped, hiddenColumns); } return mapRow(mapped); }); @@ -65,6 +67,7 @@ export function dispatchMutationRows( runtime, tableName, modelName, + namespaceId, identityRows, selectedFields, includes, @@ -80,6 +83,7 @@ interface DispatchSplitMutationRowsOptions { readonly plans: ReadonlyArray>>; readonly tableName: string; readonly modelName: string; + readonly namespaceId: string; readonly includes: readonly IncludeExpr[]; readonly selectedFields: readonly string[] | undefined; readonly hiddenColumns: readonly string[]; @@ -95,6 +99,7 @@ export function dispatchSplitMutationRows( plans, tableName, modelName, + namespaceId, includes, selectedFields, hiddenColumns, @@ -114,6 +119,7 @@ export function dispatchSplitMutationRows( runtime, tableName, modelName, + namespaceId, identityRows, selectedFields, includes, @@ -123,9 +129,9 @@ export function dispatchSplitMutationRows( for (const plan of plans) { for await (const rawRow of executeQueryPlan>(runtime, plan)) { - const mapped = mapStorageRowToModelFields(contract, modelName, rawRow); + const mapped = mapStorageRowToModelFields(contract, namespaceId, modelName, rawRow); if (hiddenColumns.length > 0) { - stripHiddenMappedFields(contract, modelName, mapped, hiddenColumns); + stripHiddenMappedFields(contract, namespaceId, modelName, mapped, hiddenColumns); } yield mapRow(mapped); } @@ -141,6 +147,7 @@ interface ExecuteSingleMutationOptions { readonly compiled: SqlQueryPlan>; readonly tableName: string; readonly modelName: string; + readonly namespaceId: string; readonly includes: readonly IncludeExpr[]; readonly selectedFields: readonly string[] | undefined; readonly hiddenColumns: readonly string[]; @@ -157,6 +164,7 @@ export async function executeMutationReturningSingleRow( compiled, tableName, modelName, + namespaceId, includes, selectedFields, hiddenColumns, @@ -171,9 +179,9 @@ export async function executeMutationReturningSingleRow( return null; } - const mapped = mapStorageRowToModelFields(contract, modelName, first); + const mapped = mapStorageRowToModelFields(contract, namespaceId, modelName, first); if (hiddenColumns.length > 0) { - stripHiddenMappedFields(contract, modelName, mapped, hiddenColumns); + stripHiddenMappedFields(contract, namespaceId, modelName, mapped, hiddenColumns); } return mapRow(mapped); } @@ -190,6 +198,7 @@ export async function executeMutationReturningSingleRow( runtime, tableName, modelName, + namespaceId, identityRows, selectedFields, includes, diff --git a/packages/3-extensions/sql-orm-client/src/collection-runtime.ts b/packages/3-extensions/sql-orm-client/src/collection-runtime.ts index 7285550698..d19c9c09ea 100644 --- a/packages/3-extensions/sql-orm-client/src/collection-runtime.ts +++ b/packages/3-extensions/sql-orm-client/src/collection-runtime.ts @@ -17,6 +17,7 @@ export interface RowEnvelope { export function stripHiddenMappedFields( contract: Contract, + namespaceId: string, modelName: string, mapped: Record, hiddenColumns: readonly string[], @@ -25,7 +26,7 @@ export function stripHiddenMappedFields( return; } - const columnToField = getColumnToFieldMap(contract, modelName); + const columnToField = getColumnToFieldMap(contract, namespaceId, modelName); for (const hiddenColumn of hiddenColumns) { const fieldName = columnToField[hiddenColumn] ?? hiddenColumn; delete mapped[fieldName]; @@ -34,21 +35,23 @@ export function stripHiddenMappedFields( export function createRowEnvelope( contract: Contract, + namespaceId: string, modelName: string, raw: Record, ): RowEnvelope { return { raw, - mapped: mapStorageRowToModelFields(contract, modelName, raw), + mapped: mapStorageRowToModelFields(contract, namespaceId, modelName, raw), }; } export function mapStorageRowToModelFields( contract: Contract, + namespaceId: string, modelName: string, row: Record, ): Record { - const columnToField = getColumnToFieldMap(contract, modelName); + const columnToField = getColumnToFieldMap(contract, namespaceId, modelName); if (Object.keys(columnToField).length === 0) { return { ...row }; } @@ -64,11 +67,12 @@ const mergedColumnToFieldCache = new WeakMap, + namespaceId: string, baseModelName: string, variantModelName: string, variantTable: string | undefined, ): Record { - const cacheKey = `${baseModelName}:${variantModelName}:${variantTable ?? ''}`; + const cacheKey = `${namespaceId}:${baseModelName}:${variantModelName}:${variantTable ?? ''}`; let perContract = mergedColumnToFieldCache.get(contract); if (!perContract) { perContract = new Map(); @@ -77,8 +81,8 @@ function getMergedColumnToFieldMap( const cached = perContract.get(cacheKey); if (cached) return cached; - const baseMap = getCompleteColumnToFieldMap(contract, baseModelName); - const variantMap = getCompleteColumnToFieldMap(contract, variantModelName); + const baseMap = getCompleteColumnToFieldMap(contract, namespaceId, baseModelName); + const variantMap = getCompleteColumnToFieldMap(contract, namespaceId, variantModelName); const merged: Record = { ...baseMap }; for (const [col, field] of Object.entries(variantMap)) { @@ -95,6 +99,7 @@ function getMergedColumnToFieldMap( export function mapPolymorphicRow( contract: Contract, + namespaceId: string, baseModelName: string, polyInfo: PolymorphismInfo, row: Record, @@ -105,7 +110,7 @@ export function mapPolymorphicRow( : polyInfo.variantsByValue.get(row[polyInfo.discriminatorColumn] as string); if (!variant) { - const baseMap = getCompleteColumnToFieldMap(contract, baseModelName); + const baseMap = getCompleteColumnToFieldMap(contract, namespaceId, baseModelName); const mapped: Record = {}; for (const [col, val] of Object.entries(row)) { const field = baseMap[col]; @@ -117,7 +122,13 @@ export function mapPolymorphicRow( } const mtiTable = variant.strategy === 'mti' ? variant.table : undefined; - const mergedMap = getMergedColumnToFieldMap(contract, baseModelName, variant.modelName, mtiTable); + const mergedMap = getMergedColumnToFieldMap( + contract, + namespaceId, + baseModelName, + variant.modelName, + mtiTable, + ); const mapped: Record = {}; for (const [col, val] of Object.entries(row)) { const field = mergedMap[col]; @@ -130,10 +141,11 @@ export function mapPolymorphicRow( export function mapModelDataToStorageRow( contract: Contract, + namespaceId: string, modelName: string, row: Record, ): Record { - const fieldToColumn = getFieldToColumnMap(contract, modelName); + const fieldToColumn = getFieldToColumnMap(contract, namespaceId, modelName); const mapped: Record = {}; for (const [fieldName, value] of Object.entries(row)) { if (value === undefined) { diff --git a/packages/3-extensions/sql-orm-client/src/collection.ts b/packages/3-extensions/sql-orm-client/src/collection.ts index db4af5754b..659f2b4167 100644 --- a/packages/3-extensions/sql-orm-client/src/collection.ts +++ b/packages/3-extensions/sql-orm-client/src/collection.ts @@ -203,6 +203,8 @@ export class Collection< /** @internal */ readonly tableName: string; /** @internal */ + readonly namespaceId: string; + /** @internal */ readonly state: CollectionState; /** @internal */ readonly registry: ReadonlyMap>; @@ -212,12 +214,14 @@ export class Collection< constructor( ctx: CollectionContext, modelName: ModelName, - options: CollectionInit = {}, + options: CollectionInit, ) { this.ctx = ctx; this.contract = ctx.context.contract; this.modelName = modelName; - this.tableName = options.tableName ?? resolveModelTableName(this.contract, modelName); + this.namespaceId = options.namespaceId; + this.tableName = + options.tableName ?? resolveModelTableName(this.contract, options.namespaceId, modelName); this.state = options.state ?? emptyState(); this.registry = options.registry ?? new Map>(); this.includeRefinementMode = options.includeRefinementMode ?? false; @@ -279,12 +283,22 @@ export class Collection< blindCast< VariantAwareModelAccessor, 'runtime accessor carries the selected variant fields; the variant widening is callback-param-only' - >(createModelAccessor(this.ctx.context, this.modelName, this.state.variantName)), + >( + createModelAccessor( + this.ctx.context, + this.namespaceId, + this.modelName, + this.state.variantName, + ), + ), ) : isWhereDirectInput(input) ? input - : shorthandToWhereExpr(this.ctx.context, this.modelName, input); - const filter = normalizeWhereArg(whereArg, { contract: this.contract }); + : shorthandToWhereExpr(this.ctx.context, this.namespaceId, this.modelName, input); + const filter = normalizeWhereArg(whereArg, { + contract: this.contract, + namespaceId: this.namespaceId, + }); if (!filter) { return this as Collection>; @@ -323,7 +337,9 @@ export class Collection< WithVariantState, V> > { type ReturnState = WithVariantState, V>; - const model = modelOf(this.contract, this.modelName) as Record | undefined; + const model = modelOf(this.contract, this.namespaceId, this.modelName) as + | Record + | undefined; const discriminator = model?.['discriminator'] as { field: string } | undefined; const variants = model?.['variants'] as Record | undefined; @@ -346,7 +362,12 @@ export class Collection< >; } - const columnName = resolveFieldToColumn(this.contract, this.modelName, discriminator.field); + const columnName = resolveFieldToColumn( + this.contract, + this.namespaceId, + this.modelName, + discriminator.field, + ); const filter = BinaryExpr.eq( ColumnRef.of(this.tableName, columnName), LiteralExpr.of(variantEntry.value), @@ -445,7 +466,12 @@ export class Collection< >, State > { - const relation = resolveIncludeRelation(this.contract, this.modelName, relationName as string); + const relation = resolveIncludeRelation( + this.contract, + this.namespaceId, + this.modelName, + relationName as string, + ); let nestedState = emptyState(); let scalarSelector: IncludeScalar | undefined; @@ -458,6 +484,7 @@ export class Collection< DefaultCollectionTypeState >(relation.relatedModelName as RelatedName, { tableName: relation.relatedTableName, + namespaceId: relation.relatedNamespaceId, state: emptyState(), includeRefinementMode: true, }); @@ -498,6 +525,7 @@ export class Collection< const includeExpr: IncludeExpr = { relationName: relationName as string, relatedModelName: relation.relatedModelName, + relatedNamespaceId: relation.relatedNamespaceId, relatedTableName: relation.relatedTableName, targetColumn: relation.targetColumn, localColumn: relation.localColumn, @@ -555,7 +583,12 @@ export class Collection< >, State > { - const selectedFields = mapFieldsToColumns(this.contract, this.modelName, fields); + const selectedFields = mapFieldsToColumns( + this.contract, + this.namespaceId, + this.modelName, + fields, + ); return this.#cloneWithRow< SimplifyDeep< @@ -590,7 +623,7 @@ export class Collection< | ((model: ModelAccessor) => OrderByItem) | ReadonlyArray<(model: ModelAccessor) => OrderByItem>, ): Collection> { - const accessor = createModelAccessor(this.ctx.context, this.modelName); + const accessor = createModelAccessor(this.ctx.context, this.namespaceId, this.modelName); const selectors = Array.isArray(selection) ? selection : [selection]; const nextOrders = selectors.map((selector) => selector(accessor as ModelAccessor), @@ -620,10 +653,16 @@ export class Collection< ...(keyof DefaultModelRow & string)[], ], >(...fields: Fields): GroupedCollection { - const groupByColumns = mapFieldsToColumns(this.contract, this.modelName, fields); + const groupByColumns = mapFieldsToColumns( + this.contract, + this.namespaceId, + this.modelName, + fields, + ); return new GroupedCollection(this.ctx, this.modelName, { tableName: this.tableName, + namespaceId: this.namespaceId, baseFilters: this.state.filters, groupByFields: [...fields], groupByColumns, @@ -663,7 +702,12 @@ export class Collection< field: FieldName, ): IncludeScalar { this.#assertIncludeRefinementMode('sum()'); - const columnName = resolveFieldToColumn(this.contract, this.modelName, field as string); + const columnName = resolveFieldToColumn( + this.contract, + this.namespaceId, + this.modelName, + field as string, + ); return createIncludeScalar('sum', this.state, columnName); } @@ -682,7 +726,12 @@ export class Collection< field: FieldName, ): IncludeScalar { this.#assertIncludeRefinementMode('avg()'); - const columnName = resolveFieldToColumn(this.contract, this.modelName, field as string); + const columnName = resolveFieldToColumn( + this.contract, + this.namespaceId, + this.modelName, + field as string, + ); return createIncludeScalar('avg', this.state, columnName); } @@ -700,7 +749,12 @@ export class Collection< field: FieldName, ): IncludeScalar { this.#assertIncludeRefinementMode('min()'); - const columnName = resolveFieldToColumn(this.contract, this.modelName, field as string); + const columnName = resolveFieldToColumn( + this.contract, + this.namespaceId, + this.modelName, + field as string, + ); return createIncludeScalar('min', this.state, columnName); } @@ -718,7 +772,12 @@ export class Collection< field: FieldName, ): IncludeScalar { this.#assertIncludeRefinementMode('max()'); - const columnName = resolveFieldToColumn(this.contract, this.modelName, field as string); + const columnName = resolveFieldToColumn( + this.contract, + this.namespaceId, + this.modelName, + field as string, + ); return createIncludeScalar('max', this.state, columnName); } @@ -816,6 +875,7 @@ export class Collection< ): Collection { const mappedCursor = mapCursorValuesToColumns( this.contract, + this.namespaceId, this.modelName, cursorValues as Readonly>, ); @@ -843,7 +903,12 @@ export class Collection< ...(keyof DefaultModelRow & string)[], ], >(...fields: Fields): Collection { - const distinctFields = mapFieldsToColumns(this.contract, this.modelName, fields); + const distinctFields = mapFieldsToColumns( + this.contract, + this.namespaceId, + this.modelName, + fields, + ); return this.#clone({ distinct: distinctFields, @@ -875,6 +940,7 @@ export class Collection< ): Collection { const distinctOnFields = mapFieldsToColumns( this.contract, + this.namespaceId, this.modelName, fields as readonly string[], ); @@ -1037,7 +1103,9 @@ export class Collection< fn: (aggregate: AggregateBuilder) => Spec, configure?: (meta: MetaBuilder<'read'>) => void, ): Promise> { - const aggregateSpec = fn(createAggregateBuilder(this.contract, this.modelName)); + const aggregateSpec = fn( + createAggregateBuilder(this.contract, this.namespaceId, this.modelName), + ); const entries = Object.entries(aggregateSpec); if (entries.length === 0) { throw new Error('aggregate() requires at least one aggregation selector'); @@ -1052,7 +1120,13 @@ export class Collection< const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'read', 'aggregate'); const compiled = mergeAnnotations( - compileAggregate(this.contract, this.tableName, this.state.filters, aggregateSpec), + compileAggregate( + this.contract, + this.namespaceId, + this.tableName, + this.state.filters, + aggregateSpec, + ), annotationsMap, ); const rows = await executeQueryPlan>( @@ -1126,16 +1200,27 @@ export class Collection< const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'create'); if ( - hasNestedMutationCallbacks(this.contract, this.modelName, data as Record) + hasNestedMutationCallbacks( + this.contract, + this.namespaceId, + this.modelName, + data as Record, + ) ) { const createdRow = await executeNestedCreateMutation({ context: this.ctx.context, runtime: this.ctx.runtime, + namespaceId: this.namespaceId, modelName: this.modelName, data: data as MutationCreateInput, string>, }); - const pkCriterion = buildPrimaryKeyFilterFromRow(this.contract, this.modelName, createdRow); + const pkCriterion = buildPrimaryKeyFilterFromRow( + this.contract, + this.namespaceId, + this.modelName, + createdRow, + ); const reloaded = await this.#reloadMutationRowByPrimaryKey(pkCriterion); if (!reloaded) { throw new Error(`create() for model "${this.modelName}" did not return a row`); @@ -1215,6 +1300,7 @@ export class Collection< if (this.contract.capabilities?.['sql']?.['defaultInInsert'] !== true) { const plans = compileInsertReturningSplit( this.contract, + this.namespaceId, this.tableName, mappedRows, selectedForInsert, @@ -1225,6 +1311,7 @@ export class Collection< plans, tableName: this.tableName, modelName: this.modelName, + namespaceId: this.namespaceId, includes: this.state.includes, selectedFields: this.state.selectedFields, hiddenColumns, @@ -1233,7 +1320,13 @@ export class Collection< } const compiled = mergeAnnotations( - compileInsertReturning(this.contract, this.tableName, mappedRows, selectedForInsert), + compileInsertReturning( + this.contract, + this.namespaceId, + this.tableName, + mappedRows, + selectedForInsert, + ), annotationsMap, ); return dispatchMutationRows({ @@ -1242,6 +1335,7 @@ export class Collection< compiled, tableName: this.tableName, modelName: this.modelName, + namespaceId: this.namespaceId, includes: this.state.includes, selectedFields: this.state.selectedFields, hiddenColumns, @@ -1262,15 +1356,19 @@ export class Collection< const variantName = this.state.variantName; if (!variantName) return null; - const polyInfo = resolvePolymorphismInfo(this.contract, this.modelName); + const polyInfo = resolvePolymorphismInfo(this.contract, this.namespaceId, this.modelName); if (!polyInfo) return null; const variant = polyInfo.variants.get(variantName); if (!variant || variant.strategy !== 'mti') return null; - const baseFieldToColumn = getFieldToColumnMap(this.contract, this.modelName); - const variantFieldToColumn = getFieldToColumnMap(this.contract, variant.modelName); - const pkColumn = resolvePrimaryKeyColumn(this.contract, this.tableName); + const baseFieldToColumn = getFieldToColumnMap(this.contract, this.namespaceId, this.modelName); + const variantFieldToColumn = getFieldToColumnMap( + this.contract, + this.namespaceId, + variant.modelName, + ); + const pkColumn = resolvePrimaryKeyColumn(this.contract, this.namespaceId, this.tableName); return { polyInfo, @@ -1291,6 +1389,7 @@ export class Collection< const runtime = collectionCtx.runtime; const tableName = this.tableName; const modelName = this.modelName; + const namespaceId = this.namespaceId; const baseFieldColumns = new Set(Object.values(baseFieldToColumn)); const variantFieldColumns = new Set(Object.values(variantFieldToColumn)); @@ -1319,7 +1418,13 @@ export class Collection< const merged = await withMutationScope(runtime, async (scope) => { applyCreateDefaults(collectionCtx, tableName, [baseRow]); - const baseCompiled = compileInsertReturning(contract, tableName, [baseRow], undefined); + const baseCompiled = compileInsertReturning( + contract, + namespaceId, + tableName, + [baseRow], + undefined, + ); const baseResult = await executeQueryPlan>( scope, baseCompiled, @@ -1334,6 +1439,7 @@ export class Collection< applyCreateDefaults(collectionCtx, variant.table, [variantRow]); const variantCompiled = compileInsertReturning( contract, + namespaceId, variant.table, [variantRow], undefined, @@ -1357,6 +1463,7 @@ export class Collection< return mapPolymorphicRow( contract, + namespaceId, modelName, polyInfo, { ...baseCreated, ...prefixedVariant }, @@ -1374,21 +1481,31 @@ export class Collection< #mapCreateRows(data: readonly Record[]): Record[] { const variantName = this.state.variantName; if (!variantName) { - return data.map((row) => mapModelDataToStorageRow(this.contract, this.modelName, row)); + return data.map((row) => + mapModelDataToStorageRow(this.contract, this.namespaceId, this.modelName, row), + ); } - const polyInfo = resolvePolymorphismInfo(this.contract, this.modelName); + const polyInfo = resolvePolymorphismInfo(this.contract, this.namespaceId, this.modelName); if (!polyInfo) { - return data.map((row) => mapModelDataToStorageRow(this.contract, this.modelName, row)); + return data.map((row) => + mapModelDataToStorageRow(this.contract, this.namespaceId, this.modelName, row), + ); } const variant = polyInfo.variants.get(variantName); if (!variant) { - return data.map((row) => mapModelDataToStorageRow(this.contract, this.modelName, row)); + return data.map((row) => + mapModelDataToStorageRow(this.contract, this.namespaceId, this.modelName, row), + ); } - const baseFieldToColumn = getFieldToColumnMap(this.contract, this.modelName); - const variantFieldToColumn = getFieldToColumnMap(this.contract, variant.modelName); + const baseFieldToColumn = getFieldToColumnMap(this.contract, this.namespaceId, this.modelName); + const variantFieldToColumn = getFieldToColumnMap( + this.contract, + this.namespaceId, + variant.modelName, + ); const mergedFieldToColumn = { ...baseFieldToColumn, ...variantFieldToColumn }; return data.map((row) => { @@ -1437,9 +1554,12 @@ export class Collection< applyCreateDefaults(this.ctx, this.tableName, mappedRows); if (this.contract.capabilities?.['sql']?.['defaultInInsert'] !== true) { - const plans = compileInsertCountSplit(this.contract, this.tableName, mappedRows).map((plan) => - mergeAnnotations(plan, annotationsMap), - ); + const plans = compileInsertCountSplit( + this.contract, + this.namespaceId, + this.tableName, + mappedRows, + ).map((plan) => mergeAnnotations(plan, annotationsMap)); for (const plan of plans) { await executeQueryPlan>(this.ctx.runtime, plan).toArray(); } @@ -1447,7 +1567,7 @@ export class Collection< } const compiled = mergeAnnotations( - compileInsertCount(this.contract, this.tableName, mappedRows), + compileInsertCount(this.contract, this.namespaceId, this.tableName, mappedRows), annotationsMap, ); await executeQueryPlan>(this.ctx.runtime, compiled).toArray(); @@ -1501,13 +1621,19 @@ export class Collection< const mappedCreateRows = this.#mapCreateRows([input.create as Record]); const createValues = mappedCreateRows[0] ?? {}; applyCreateDefaults(this.ctx, this.tableName, [createValues]); - const updateValues = mapModelDataToStorageRow(this.contract, this.modelName, input.update); + const updateValues = mapModelDataToStorageRow( + this.contract, + this.namespaceId, + this.modelName, + input.update, + ); const hasUpdateValues = Object.keys(updateValues).length > 0; if (hasUpdateValues) { applyUpdateDefaults(this.ctx, this.tableName, updateValues); } const conflictColumns = resolveUpsertConflictColumns( this.contract, + this.namespaceId, this.modelName, input.conflictOn as Record | undefined, ); @@ -1519,6 +1645,7 @@ export class Collection< const compiled = mergeAnnotations( compileUpsertReturning( this.contract, + this.namespaceId, this.tableName, createValues, updateValues, @@ -1533,6 +1660,7 @@ export class Collection< compiled, tableName: this.tableName, modelName: this.modelName, + namespaceId: this.namespaceId, includes: this.state.includes, selectedFields: this.state.selectedFields, hiddenColumns, @@ -1601,11 +1729,17 @@ export class Collection< const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'update'); if ( - hasNestedMutationCallbacks(this.contract, this.modelName, data as Record) + hasNestedMutationCallbacks( + this.contract, + this.namespaceId, + this.modelName, + data as Record, + ) ) { const updatedRow = await executeNestedUpdateMutation({ context: this.ctx.context, runtime: this.ctx.runtime, + namespaceId: this.namespaceId, modelName: this.modelName, filters: this.state.filters, data: data as MutationUpdateInput, string>, @@ -1614,7 +1748,12 @@ export class Collection< return null; } - const pkCriterion = buildPrimaryKeyFilterFromRow(this.contract, this.modelName, updatedRow); + const pkCriterion = buildPrimaryKeyFilterFromRow( + this.contract, + this.namespaceId, + this.modelName, + updatedRow, + ); return this.#reloadMutationRowByPrimaryKey(pkCriterion); } @@ -1676,7 +1815,12 @@ export class Collection< ): AsyncIterableResult { assertReturningCapability(this.contract, 'updateAll()'); - const mappedData = mapModelDataToStorageRow(this.contract, this.modelName, data); + const mappedData = mapModelDataToStorageRow( + this.contract, + this.namespaceId, + this.modelName, + data, + ); if (Object.keys(mappedData).length === 0) { const generator = async function* (): AsyncGenerator {}; return new AsyncIterableResult(generator()); @@ -1688,6 +1832,7 @@ export class Collection< const compiled = mergeAnnotations( compileUpdateReturning( this.contract, + this.namespaceId, this.tableName, mappedData, this.state.filters, @@ -1701,6 +1846,7 @@ export class Collection< compiled, tableName: this.tableName, modelName: this.modelName, + namespaceId: this.namespaceId, includes: this.state.includes, selectedFields: this.state.selectedFields, hiddenColumns, @@ -1726,7 +1872,12 @@ export class Collection< data: State['hasWhere'] extends true ? Partial> : never, configure?: (meta: MetaBuilder<'write'>) => void, ): Promise { - const mappedData = mapModelDataToStorageRow(this.contract, this.modelName, data); + const mappedData = mapModelDataToStorageRow( + this.contract, + this.namespaceId, + this.modelName, + data, + ); if (Object.keys(mappedData).length === 0) { return 0; } @@ -1736,20 +1887,36 @@ export class Collection< // Annotations attach to the write, not the matching read. const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'updateCount'); - const primaryKeyColumn = resolvePrimaryKeyColumn(this.contract, this.tableName); + const primaryKeyColumn = resolvePrimaryKeyColumn( + this.contract, + this.namespaceId, + this.tableName, + ); const countState: CollectionState = { ...emptyState(), filters: this.state.filters, selectedFields: [primaryKeyColumn], }; - const countCompiled = compileSelect(this.contract, this.tableName, countState); + const countCompiled = compileSelect( + this.contract, + this.namespaceId, + this.tableName, + countState, + undefined, + ); const matchingRows = await executeQueryPlan>( this.ctx.runtime, countCompiled, ).toArray(); const compiled = mergeAnnotations( - compileUpdateCount(this.contract, this.tableName, mappedData, this.state.filters), + compileUpdateCount( + this.contract, + this.namespaceId, + this.tableName, + mappedData, + this.state.filters, + ), annotationsMap, ); await executeQueryPlan>(this.ctx.runtime, compiled).toArray(); @@ -1836,7 +2003,13 @@ export class Collection< const { selectedForQuery: selectedForDelete, hiddenColumns } = this.#augmentMutationSelection(); const compiled = mergeAnnotations( - compileDeleteReturning(this.contract, this.tableName, this.state.filters, selectedForDelete), + compileDeleteReturning( + this.contract, + this.namespaceId, + this.tableName, + this.state.filters, + selectedForDelete, + ), annotationsMap, ); return dispatchMutationRows({ @@ -1845,6 +2018,7 @@ export class Collection< compiled, tableName: this.tableName, modelName: this.modelName, + namespaceId: this.namespaceId, includes: this.state.includes, selectedFields: this.state.selectedFields, hiddenColumns, @@ -1877,9 +2051,15 @@ export class Collection< state: collection.state, tableName: collection.tableName, modelName: collection.modelName, + namespaceId: collection.namespaceId, }).toArray(); const deletePlan = mergeAnnotations( - compileDeleteCount(collection.contract, collection.tableName, collection.state.filters), + compileDeleteCount( + collection.contract, + collection.namespaceId, + collection.tableName, + collection.state.filters, + ), annotationsMap, ); await executeQueryPlan>(scope, deletePlan).toArray(); @@ -1911,20 +2091,30 @@ export class Collection< // Annotations attach to the write, not the matching read. const annotationsMap = this.#collectAnnotationsFromMeta(configure, 'write', 'deleteCount'); - const primaryKeyColumn = resolvePrimaryKeyColumn(this.contract, this.tableName); + const primaryKeyColumn = resolvePrimaryKeyColumn( + this.contract, + this.namespaceId, + this.tableName, + ); const countState: CollectionState = { ...emptyState(), filters: this.state.filters, selectedFields: [primaryKeyColumn], }; - const countCompiled = compileSelect(this.contract, this.tableName, countState); + const countCompiled = compileSelect( + this.contract, + this.namespaceId, + this.tableName, + countState, + undefined, + ); const matchingRows = await executeQueryPlan>( this.ctx.runtime, countCompiled, ).toArray(); const compiled = mergeAnnotations( - compileDeleteCount(this.contract, this.tableName, this.state.filters), + compileDeleteCount(this.contract, this.namespaceId, this.tableName, this.state.filters), annotationsMap, ); await executeQueryPlan>(this.ctx.runtime, compiled).toArray(); @@ -1936,7 +2126,7 @@ export class Collection< createValues: Record, conflictColumns: readonly string[], ): Record { - const columnToField = getColumnToFieldMap(this.contract, this.modelName); + const columnToField = getColumnToFieldMap(this.contract, this.namespaceId, this.modelName); const criterion: Record = {}; for (const columnName of conflictColumns) { @@ -1968,7 +2158,11 @@ export class Collection< hiddenColumns: readonly string[]; } { if (this.state.includes.length > 0) { - const identityColumns = resolveRowIdentityColumns(this.contract, this.tableName); + const identityColumns = resolveRowIdentityColumns( + this.contract, + this.namespaceId, + this.tableName, + ); if (identityColumns.length === 0) { throw new Error( `Cannot load includes for the mutation result on model "${this.modelName}": table "${this.tableName}" has no primary key or unique constraint to key the include read-back on.`, @@ -1980,7 +2174,11 @@ export class Collection< } async #findFirstMatchingRowIdentityWhere(): Promise { - const identityColumns = resolveRowIdentityColumns(this.contract, this.tableName); + const identityColumns = resolveRowIdentityColumns( + this.contract, + this.namespaceId, + this.tableName, + ); if (identityColumns.length === 0) { throw new Error( `update()/delete() on model "${this.modelName}" requires the table to have a primary key or unique constraint`, @@ -1993,7 +2191,7 @@ export class Collection< if (!firstRow) { return null; } - const columnToField = getColumnToFieldMap(this.contract, this.modelName); + const columnToField = getColumnToFieldMap(this.contract, this.namespaceId, this.modelName); const criterion: Record = {}; for (const column of identityColumns) { const fieldName = columnToField[column] ?? column; @@ -2008,6 +2206,7 @@ export class Collection< return ( shorthandToWhereExpr( this.ctx.context, + this.namespaceId, this.modelName, criterion as ShorthandWhereFilter, ) ?? null @@ -2024,6 +2223,7 @@ export class Collection< ): Promise { const whereExpr = shorthandToWhereExpr( this.ctx.context, + this.namespaceId, this.modelName, criterion as ShorthandWhereFilter, ); @@ -2047,6 +2247,7 @@ export class Collection< state: resultState, tableName: this.tableName, modelName: this.modelName, + namespaceId: this.namespaceId, }); return rows[0] ?? null; } @@ -2072,6 +2273,7 @@ export class Collection< const Ctor = this.constructor as CollectionConstructor; return new Ctor({ ...this.ctx, runtime }, this.modelName, { tableName: this.tableName, + namespaceId: this.namespaceId, state: this.state, registry: this.registry, includeRefinementMode: this.includeRefinementMode, @@ -2093,6 +2295,7 @@ export class Collection< const Ctor = this.constructor as CollectionConstructor; return new Ctor(this.ctx, this.modelName, { tableName: this.tableName, + namespaceId: this.namespaceId, state, registry: this.registry, includeRefinementMode: this.includeRefinementMode, @@ -2112,6 +2315,7 @@ export class Collection< (Collection as unknown as CollectionConstructor); return new Ctor(this.ctx, modelName, { tableName: options.tableName, + namespaceId: options.namespaceId, state: options.state, registry: options.registry ?? @@ -2127,6 +2331,7 @@ export class Collection< state: this.state, tableName: this.tableName, modelName: this.modelName, + namespaceId: this.namespaceId, }); } diff --git a/packages/3-extensions/sql-orm-client/src/filters.ts b/packages/3-extensions/sql-orm-client/src/filters.ts index 5a100a09e3..88f06f1787 100644 --- a/packages/3-extensions/sql-orm-client/src/filters.ts +++ b/packages/3-extensions/sql-orm-client/src/filters.ts @@ -34,12 +34,13 @@ export function shorthandToWhereExpr< ModelName extends string, >( context: ExecutionContext, + namespaceId: string, modelName: ModelName, filters: ShorthandWhereFilter, ): AnyExpression | undefined { const contract = context.contract; - const tableName = resolveModelTableName(contract, modelName); - const fieldToColumn = getFieldToColumnMap(contract, modelName); + const tableName = resolveModelTableName(contract, namespaceId, modelName); + const fieldToColumn = getFieldToColumnMap(contract, namespaceId, modelName); const exprs: AnyExpression[] = []; for (const [fieldName, value] of Object.entries(filters)) { @@ -55,7 +56,7 @@ export function shorthandToWhereExpr< continue; } - assertFieldHasEqualityTrait(context, modelName, fieldName); + assertFieldHasEqualityTrait(context, namespaceId, modelName, fieldName); exprs.push(BinaryExpr.eq(left, LiteralExpr.of(value))); } @@ -68,10 +69,11 @@ export function shorthandToWhereExpr< function assertFieldHasEqualityTrait( context: ExecutionContext, + namespaceId: string, modelName: string, fieldName: string, ): void { - const fieldType = modelOf(context.contract, modelName)?.fields?.[fieldName]?.type; + const fieldType = modelOf(context.contract, namespaceId, modelName)?.fields?.[fieldName]?.type; const codecId = fieldType?.kind === 'scalar' ? fieldType.codecId : undefined; const traits = codecId ? (context.codecDescriptors.descriptorFor(codecId)?.traits ?? []) : []; if (!traits.includes('equality')) { diff --git a/packages/3-extensions/sql-orm-client/src/grouped-collection.ts b/packages/3-extensions/sql-orm-client/src/grouped-collection.ts index 92a827e1c3..57ded96bbd 100644 --- a/packages/3-extensions/sql-orm-client/src/grouped-collection.ts +++ b/packages/3-extensions/sql-orm-client/src/grouped-collection.ts @@ -33,6 +33,7 @@ import { combineWhereExprs } from './where-utils'; interface GroupedCollectionInit { readonly tableName: string; + readonly namespaceId: string; readonly baseFilters: readonly AnyExpression[]; readonly groupByFields: readonly string[]; readonly groupByColumns: readonly string[]; @@ -53,6 +54,7 @@ export class GroupedCollection< private readonly contract: TContract; readonly modelName: ModelName; readonly tableName: string; + readonly namespaceId: string; readonly baseFilters: readonly AnyExpression[]; readonly groupByFields: readonly string[]; readonly groupByColumns: readonly string[]; @@ -67,6 +69,7 @@ export class GroupedCollection< this.contract = ctx.context.contract; this.modelName = modelName; this.tableName = options.tableName; + this.namespaceId = options.namespaceId; this.baseFilters = options.baseFilters; this.groupByFields = options.groupByFields; this.groupByColumns = options.groupByColumns; @@ -77,10 +80,11 @@ export class GroupedCollection< predicate: (having: HavingBuilder) => AnyExpression, ): GroupedCollection { const havingExpr = predicate( - createHavingBuilder(this.contract, this.modelName, this.tableName), + createHavingBuilder(this.contract, this.namespaceId, this.modelName, this.tableName), ); return new GroupedCollection(this.ctx, this.modelName, { tableName: this.tableName, + namespaceId: this.namespaceId, baseFilters: this.baseFilters, groupByFields: this.groupByFields, groupByColumns: this.groupByColumns, @@ -105,7 +109,9 @@ export class GroupedCollection< > > > { - const aggregateSpec = fn(createAggregateBuilder(this.contract, this.modelName)); + const aggregateSpec = fn( + createAggregateBuilder(this.contract, this.namespaceId, this.modelName), + ); const aggregateEntries = Object.entries(aggregateSpec); if (aggregateEntries.length === 0) { throw new Error('groupBy().aggregate() requires at least one aggregation selector'); @@ -129,6 +135,7 @@ export class GroupedCollection< const compiled = mergeAnnotations( compileGroupedAggregate( this.contract, + this.namespaceId, this.tableName, this.baseFilters, this.groupByColumns, @@ -143,7 +150,12 @@ export class GroupedCollection< ).toArray(); return rows.map((row) => { - const mapped = mapStorageRowToModelFields(this.contract, this.modelName, row); + const mapped = mapStorageRowToModelFields( + this.contract, + this.namespaceId, + this.modelName, + row, + ); for (const [alias, selector] of aggregateEntries) { mapped[alias] = coerceAggregateValue(selector.fn, row[alias]); } @@ -158,10 +170,11 @@ export class GroupedCollection< function createHavingBuilder, ModelName extends string>( contract: TContract, + namespaceId: string, modelName: ModelName, tableName: string, ): HavingBuilder { - const fieldToColumn = getFieldToColumnMap(contract, modelName); + const fieldToColumn = getFieldToColumnMap(contract, namespaceId, modelName); const createMetricExpr = ( fn: Exclude, fieldName: string, diff --git a/packages/3-extensions/sql-orm-client/src/model-accessor.ts b/packages/3-extensions/sql-orm-client/src/model-accessor.ts index 6d72d7669b..86f273c12f 100644 --- a/packages/3-extensions/sql-orm-client/src/model-accessor.ts +++ b/packages/3-extensions/sql-orm-client/src/model-accessor.ts @@ -44,13 +44,14 @@ export function createModelAccessor< ModelName extends string, >( context: ExecutionContext, + namespaceId: string, modelName: ModelName, variantName?: string, ): ModelAccessor { const contract = context.contract; - const fieldToColumn = getFieldToColumnMap(contract, modelName); - const tableName = resolveModelTableName(contract, modelName); - const modelRelations = resolveModelRelations(contract, modelName); + const fieldToColumn = getFieldToColumnMap(contract, namespaceId, modelName); + const tableName = resolveModelTableName(contract, namespaceId, modelName); + const modelRelations = resolveModelRelations(contract, namespaceId, modelName); // When a variant is selected, MTI variant-owned fields resolve to a // `ColumnRef` qualified against the variant table the read path joins into // the correlated child SELECT. STI variant columns live on the base table @@ -60,7 +61,7 @@ export function createModelAccessor< // added: an empty `variantFieldColumns`, so every field falls through to the // base-table column resolution below. const variantFieldColumns: Record = variantName - ? resolveVariantFieldColumns(contract, modelName, variantName) + ? resolveVariantFieldColumns(contract, namespaceId, modelName, variantName) : {}; const opsByCodecId = new Map(); @@ -98,13 +99,13 @@ export function createModelAccessor< const relation = modelRelations[prop]; if (relation) { - return createRelationFilterAccessor(context, modelName, tableName, relation); + return createRelationFilterAccessor(context, namespaceId, modelName, tableName, relation); } const variantField = variantFieldColumns[prop]; const resolvedTable = variantField?.table ?? tableName; const columnName = variantField?.column ?? fieldToColumn[prop] ?? prop; - const column = resolveColumn(contract, resolvedTable, columnName); + const column = resolveColumn(contract, namespaceId, resolvedTable, columnName); // Unknown fields return `undefined`, matching plain JS object semantics. // The `ModelAccessor` type already rejects typos // at compile time for TS consumers, and contexts that iterate accessor @@ -115,7 +116,12 @@ export function createModelAccessor< } const traits = context.codecDescriptors.descriptorFor(column.codecId)?.traits ?? []; const operations = opsByCodecId.get(column.codecId) ?? []; - const codec = codecRefForStorageColumn(contract.storage, resolvedTable, columnName); + const codec = codecRefForStorageColumn( + contract.storage, + namespaceId, + resolvedTable, + columnName, + ); return createScalarFieldAccessor( resolvedTable, columnName, @@ -132,12 +138,13 @@ export function createModelAccessor< function resolveColumn( contract: Contract, + namespaceId: string, tableName: string, columnName: string, ): { readonly codecId: string; readonly nullable: boolean } | undefined { let table: StorageTable; try { - table = storageTableForContract(contract, tableName); + table = storageTableForContract(contract, namespaceId, tableName); } catch { return undefined; } @@ -214,28 +221,48 @@ function createRelationFilterAccessor< ParentModelName extends string, >( context: ExecutionContext, + parentNamespaceId: string, parentModelName: ParentModelName, parentTableName: string, relation: ResolvedModelRelation, ): RelationFilterAccessor { - const relatedTableName = resolveModelTableName(context.contract, relation.to); + const relatedTableName = resolveModelTableName( + context.contract, + relation.toNamespace, + relation.to, + ); const relationAccessor: RelationFilterAccessor = { some: (predicate) => - buildExistsExpr(context, parentModelName, parentTableName, relatedTableName, relation, { - mode: 'some', - predicate, - }), + buildExistsExpr( + context, + parentNamespaceId, + parentModelName, + parentTableName, + relatedTableName, + relation, + { mode: 'some', predicate }, + ), every: (predicate) => - buildExistsExpr(context, parentModelName, parentTableName, relatedTableName, relation, { - mode: 'every', - predicate, - }), + buildExistsExpr( + context, + parentNamespaceId, + parentModelName, + parentTableName, + relatedTableName, + relation, + { mode: 'every', predicate }, + ), none: (predicate) => - buildExistsExpr(context, parentModelName, parentTableName, relatedTableName, relation, { - mode: 'none', - predicate, - }), + buildExistsExpr( + context, + parentNamespaceId, + parentModelName, + parentTableName, + relatedTableName, + relation, + { mode: 'none', predicate }, + ), }; return relationAccessor; @@ -243,6 +270,7 @@ function createRelationFilterAccessor< function buildExistsExpr>( context: ExecutionContext, + parentNamespaceId: string, parentModelName: string, parentTableName: string, relatedTableName: string, @@ -254,12 +282,18 @@ function buildExistsExpr>( ): AnyExpression { const joinWhere = buildJoinWhere( context.contract, + parentNamespaceId, parentModelName, parentTableName, relatedTableName, relation, ); - const childWhere = toRelationWhereExpr(context, relation.to, options.predicate); + const childWhere = toRelationWhereExpr( + context, + relation.toNamespace, + relation.to, + options.predicate, + ); let subqueryWhere = joinWhere; let existsNot = false; @@ -280,7 +314,9 @@ function buildExistsExpr>( } const selectProjectionColumn = firstTargetColumn(context.contract, relation) ?? 'id'; - const subquery = SelectAst.from(tableSourceForContract(context.contract, relatedTableName)) + const subquery = SelectAst.from( + tableSourceForContract(context.contract, relation.toNamespace, relatedTableName), + ) .withProjection([ ProjectionItem.of('_exists', ColumnRef.of(relatedTableName, selectProjectionColumn)), ]) @@ -291,6 +327,7 @@ function buildExistsExpr>( function toRelationWhereExpr>( context: ExecutionContext, + relatedNamespaceId: string, relatedModelName: string, predicate: RelationPredicateInput | undefined, ): AnyExpression | undefined { @@ -299,7 +336,7 @@ function toRelationWhereExpr>( } // Both callback and shorthand paths use the trait-gated accessor - const accessor = createModelAccessor(context, relatedModelName); + const accessor = createModelAccessor(context, relatedNamespaceId, relatedModelName); if (typeof predicate === 'function') { return predicate(accessor); @@ -352,6 +389,7 @@ function toRelationWhereExpr>( function buildJoinWhere>( contract: TContract, + parentNamespaceId: string, parentModelName: string, parentTableName: string, relatedTableName: string, @@ -370,8 +408,18 @@ function buildJoinWhere>( continue; } - const localColumn = resolveFieldToColumn(contract, parentModelName, localField); - const targetColumn = resolveFieldToColumn(contract, relation.to, targetField); + const localColumn = resolveFieldToColumn( + contract, + parentNamespaceId, + parentModelName, + localField, + ); + const targetColumn = resolveFieldToColumn( + contract, + relation.toNamespace, + relation.to, + targetField, + ); joinExprs.push( BinaryExpr.eq( @@ -402,5 +450,5 @@ function firstTargetColumn>( if (!firstField) { return undefined; } - return resolveFieldToColumn(contract, relation.to, firstField); + return resolveFieldToColumn(contract, relation.toNamespace, relation.to, firstField); } diff --git a/packages/3-extensions/sql-orm-client/src/mutation-executor.ts b/packages/3-extensions/sql-orm-client/src/mutation-executor.ts index 39a574d18c..0d12dc9830 100644 --- a/packages/3-extensions/sql-orm-client/src/mutation-executor.ts +++ b/packages/3-extensions/sql-orm-client/src/mutation-executor.ts @@ -47,6 +47,7 @@ import { emptyState } from './types'; interface RelationDefinition { readonly relationName: string; readonly relatedModelName: string; + readonly relatedNamespaceId: string; readonly relatedTableName: string; readonly cardinality: RelationCardinalityTag | undefined; readonly localColumns: readonly string[]; @@ -65,11 +66,16 @@ interface ParsedMutationInput { export function hasNestedMutationCallbacks( contract: Contract, + namespaceId: string, modelName: string, data: Record, ): boolean { + // Only the base model's relation names are needed to detect nested-mutation + // callbacks; resolving relation targets here would eagerly resolve + // cross-namespace targets (and throw on a non-existent target namespace), + // so enumerate names directly without target resolution. const relationNames = new Set( - getRelationDefinitions(contract, modelName).map((relation) => relation.relationName), + Object.keys(resolveModelRelations(contract, namespaceId, modelName)), ); for (const [fieldName, value] of Object.entries(data)) { if (!relationNames.has(fieldName)) { @@ -86,34 +92,44 @@ export function hasNestedMutationCallbacks( export async function executeNestedCreateMutation(options: { context: ExecutionContext; runtime: RuntimeQueryable; + namespaceId: string; modelName: string; data: MutationCreateInput, string>; }): Promise> { return withMutationScope(options.runtime, async (scope) => - createGraph(scope, options.context, options.modelName, options.data), + createGraph(scope, options.context, options.namespaceId, options.modelName, options.data), ); } export async function executeNestedUpdateMutation(options: { context: ExecutionContext; runtime: RuntimeQueryable; + namespaceId: string; modelName: string; filters: readonly AnyExpression[]; data: MutationUpdateInput, string>; }): Promise | null> { return withMutationScope(options.runtime, async (scope) => - updateFirstGraph(scope, options.context, options.modelName, options.filters, options.data), + updateFirstGraph( + scope, + options.context, + options.namespaceId, + options.modelName, + options.filters, + options.data, + ), ); } export function buildPrimaryKeyFilterFromRow( contract: Contract, + namespaceId: string, modelName: string, row: Record, ): Record { - const tableName = resolveModelTableName(contract, modelName); - const primaryKeyColumn = resolvePrimaryKeyColumn(contract, tableName); - const fieldName = toFieldName(contract, modelName, primaryKeyColumn); + const tableName = resolveModelTableName(contract, namespaceId, modelName); + const primaryKeyColumn = resolvePrimaryKeyColumn(contract, namespaceId, tableName); + const fieldName = toFieldName(contract, namespaceId, modelName, primaryKeyColumn); const value = row[fieldName]; if (value === undefined) { throw new Error( @@ -159,11 +175,12 @@ export async function withMutationScope( async function createGraph( scope: RuntimeScope, context: ExecutionContext, + namespaceId: string, modelName: string, input: MutationCreateInput, string>, ): Promise> { const contract = context.contract; - const parsed = parseMutationInput(contract, modelName, input); + const parsed = parseMutationInput(contract, namespaceId, modelName, input); const { parentOwned, childOwned } = partitionByOwnership(parsed.relationMutations); const scalarData = { ...parsed.scalarData }; @@ -176,6 +193,7 @@ async function createGraph( await applyParentOwnedMutation( scope, context, + namespaceId, modelName, scalarData, relationMutation.relation, @@ -183,7 +201,7 @@ async function createGraph( ); } - const parentRow = await insertSingleRow(scope, context, modelName, scalarData); + const parentRow = await insertSingleRow(scope, context, namespaceId, modelName, scalarData); for (const relationMutation of childOwned) { if (relationMutation.mutation.kind === 'disconnect') { @@ -193,6 +211,7 @@ async function createGraph( await applyChildOwnedMutation( scope, context, + namespaceId, modelName, parentRow, relationMutation.relation, @@ -206,17 +225,23 @@ async function createGraph( async function updateFirstGraph( scope: RuntimeScope, context: ExecutionContext, + namespaceId: string, modelName: string, filters: readonly AnyExpression[], input: MutationUpdateInput, string>, ): Promise | null> { const contract = context.contract; - const existingRow = await findFirstByFilters(scope, contract, modelName, filters); + const existingRow = await findFirstByFilters(scope, contract, namespaceId, modelName, filters); if (!existingRow) { return null; } - const parsed = parseMutationInput(contract, modelName, input as Record); + const parsed = parseMutationInput( + contract, + namespaceId, + modelName, + input as Record, + ); const { parentOwned, childOwned } = partitionByOwnership(parsed.relationMutations); const scalarData = { ...parsed.scalarData }; @@ -225,6 +250,7 @@ async function updateFirstGraph( await applyParentOwnedMutation( scope, context, + namespaceId, modelName, scalarData, relationMutation.relation, @@ -234,9 +260,9 @@ async function updateFirstGraph( let parentRow = existingRow; - const mappedUpdateData = mapModelDataToStorageRow(contract, modelName, scalarData); + const mappedUpdateData = mapModelDataToStorageRow(contract, namespaceId, modelName, scalarData); if (Object.keys(mappedUpdateData).length > 0) { - const tableName = resolveModelTableName(contract, modelName); + const tableName = resolveModelTableName(contract, namespaceId, modelName); const appliedUpdateDefaults = context.applyMutationDefaults({ op: 'update', table: tableName, @@ -245,9 +271,10 @@ async function updateFirstGraph( for (const def of appliedUpdateDefaults) { mappedUpdateData[def.column] = def.value; } - const pkFilter = buildPrimaryKeyFilterFromRow(contract, modelName, existingRow); + const pkFilter = buildPrimaryKeyFilterFromRow(contract, namespaceId, modelName, existingRow); const pkWhere = shorthandToWhereExpr( context, + namespaceId, modelName, pkFilter as MutationUpdateInput, string>, ); @@ -257,6 +284,7 @@ async function updateFirstGraph( const compiled = compileUpdateReturning( contract, + namespaceId, tableName, mappedUpdateData, [pkWhere], @@ -269,7 +297,7 @@ async function updateFirstGraph( const updatedRaw = updatedRowsRaw[0]; if (updatedRaw) { - parentRow = mapStorageRowToModelFields(contract, modelName, updatedRaw); + parentRow = mapStorageRowToModelFields(contract, namespaceId, modelName, updatedRaw); } } @@ -277,6 +305,7 @@ async function updateFirstGraph( await applyChildOwnedMutation( scope, context, + namespaceId, modelName, parentRow, relationMutation.relation, @@ -289,12 +318,13 @@ async function updateFirstGraph( function parseMutationInput( contract: Contract, + namespaceId: string, modelName: string, input: Record, ): ParsedMutationInput { const scalarData: Record = {}; const relationDefinitions = new Map( - getRelationDefinitions(contract, modelName).map((relation) => [ + getRelationDefinitions(contract, namespaceId, modelName).map((relation) => [ relation.relationName, relation, ]), @@ -364,6 +394,7 @@ function partitionByOwnership(relationMutations: readonly ParsedRelationMutation async function applyParentOwnedMutation( scope: RuntimeScope, context: ExecutionContext, + parentNamespaceId: string, parentModelName: string, scalarData: Record, relation: RelationDefinition, @@ -372,7 +403,12 @@ async function applyParentOwnedMutation( const contract = context.contract; if (mutation.kind === 'disconnect') { for (const localColumn of relation.localColumns) { - const parentFieldName = toFieldName(contract, parentModelName, localColumn); + const parentFieldName = toFieldName( + contract, + parentNamespaceId, + parentModelName, + localColumn, + ); scalarData[parentFieldName] = null; } return; @@ -389,10 +425,18 @@ async function applyParentOwnedMutation( const relatedRow = await createGraph( scope, context, + relation.relatedNamespaceId, relation.relatedModelName, row as MutationCreateInput, string>, ); - copyRelatedValuesToParent(contract, parentModelName, relation, scalarData, relatedRow); + copyRelatedValuesToParent( + contract, + parentNamespaceId, + parentModelName, + relation, + scalarData, + relatedRow, + ); return; } @@ -406,6 +450,7 @@ async function applyParentOwnedMutation( const relatedRow = await findRowByCriterion( scope, context, + relation.relatedNamespaceId, relation.relatedModelName, criterion as Record, ); @@ -415,11 +460,19 @@ async function applyParentOwnedMutation( ); } - copyRelatedValuesToParent(contract, parentModelName, relation, scalarData, relatedRow); + copyRelatedValuesToParent( + contract, + parentNamespaceId, + parentModelName, + relation, + scalarData, + relatedRow, + ); } function copyRelatedValuesToParent( contract: Contract, + parentNamespaceId: string, parentModelName: string, relation: RelationDefinition, scalarData: Record, @@ -432,8 +485,13 @@ function copyRelatedValuesToParent( continue; } - const parentFieldName = toFieldName(contract, parentModelName, localColumn); - const childFieldName = toFieldName(contract, relation.relatedModelName, targetColumn); + const parentFieldName = toFieldName(contract, parentNamespaceId, parentModelName, localColumn); + const childFieldName = toFieldName( + contract, + relation.relatedNamespaceId, + relation.relatedModelName, + targetColumn, + ); scalarData[parentFieldName] = relatedRow[childFieldName]; } } @@ -441,13 +499,20 @@ function copyRelatedValuesToParent( async function applyChildOwnedMutation( scope: RuntimeScope, context: ExecutionContext, + parentNamespaceId: string, parentModelName: string, parentRow: Record, relation: RelationDefinition, mutation: RelationMutation, string>, ): Promise { const contract = context.contract; - const parentValues = readParentColumnValues(contract, parentModelName, relation, parentRow); + const parentValues = readParentColumnValues( + contract, + parentNamespaceId, + parentModelName, + relation, + parentRow, + ); if (mutation.kind === 'create') { for (const childInput of mutation.data) { @@ -456,13 +521,19 @@ async function applyChildOwnedMutation( }; for (const [childColumn, parentValue] of parentValues.entries()) { - const childFieldName = toFieldName(contract, relation.relatedModelName, childColumn); + const childFieldName = toFieldName( + contract, + relation.relatedNamespaceId, + relation.relatedModelName, + childColumn, + ); payload[childFieldName] = parentValue; } await createGraph( scope, context, + relation.relatedNamespaceId, relation.relatedModelName, payload as MutationCreateInput, string>, ); @@ -474,6 +545,7 @@ async function applyChildOwnedMutation( for (const criterion of mutation.criteria) { const criterionWhere = shorthandToWhereExpr( context, + relation.relatedNamespaceId, relation.relatedModelName, criterion as MutationUpdateInput, string>, ); @@ -488,9 +560,14 @@ async function applyChildOwnedMutation( setValues[childColumn] = parentValue; } - await executeUpdateCount(scope, contract, relation.relatedTableName, setValues, [ - criterionWhere, - ]); + await executeUpdateCount( + scope, + contract, + relation.relatedNamespaceId, + relation.relatedTableName, + setValues, + [criterionWhere], + ); } return; } @@ -502,15 +579,21 @@ async function applyChildOwnedMutation( if (!mutation.criteria || mutation.criteria.length === 0) { const parentJoinWhere = buildChildJoinWhere(relation, parentValues); - await executeUpdateCount(scope, contract, relation.relatedTableName, setValues, [ - parentJoinWhere, - ]); + await executeUpdateCount( + scope, + contract, + relation.relatedNamespaceId, + relation.relatedTableName, + setValues, + [parentJoinWhere], + ); return; } for (const criterion of mutation.criteria) { const criterionWhere = shorthandToWhereExpr( context, + relation.relatedNamespaceId, relation.relatedModelName, criterion as MutationUpdateInput, string>, ); @@ -521,14 +604,20 @@ async function applyChildOwnedMutation( } const parentJoinWhere = buildChildJoinWhere(relation, parentValues); - await executeUpdateCount(scope, contract, relation.relatedTableName, setValues, [ - and(parentJoinWhere, criterionWhere), - ]); + await executeUpdateCount( + scope, + contract, + relation.relatedNamespaceId, + relation.relatedTableName, + setValues, + [and(parentJoinWhere, criterionWhere)], + ); } } function readParentColumnValues( contract: Contract, + parentNamespaceId: string, parentModelName: string, relation: RelationDefinition, parentRow: Record, @@ -542,7 +631,7 @@ function readParentColumnValues( continue; } - const parentFieldName = toFieldName(contract, parentModelName, localColumn); + const parentFieldName = toFieldName(contract, parentNamespaceId, parentModelName, localColumn); const parentValue = parentRow[parentFieldName]; if (parentValue === undefined) { throw new Error( @@ -582,13 +671,14 @@ function buildChildJoinWhere( async function insertSingleRow( scope: RuntimeScope, context: ExecutionContext, + namespaceId: string, modelName: string, data: Record, ): Promise> { const contract = context.contract; - const tableName = resolveModelTableName(contract, modelName); + const tableName = resolveModelTableName(contract, namespaceId, modelName); - const mappedData = mapModelDataToStorageRow(contract, modelName, data); + const mappedData = mapModelDataToStorageRow(contract, namespaceId, modelName, data); const applied = context.applyMutationDefaults({ op: 'create', table: tableName, @@ -599,7 +689,13 @@ async function insertSingleRow( mappedData[def.column] = def.value; } - const compiled = compileInsertReturning(contract, tableName, [mappedData], undefined); + const compiled = compileInsertReturning( + contract, + namespaceId, + tableName, + [mappedData], + undefined, + ); const rows = await executeQueryPlan>(scope, compiled).toArray(); const firstRow = rows[0]; @@ -607,18 +703,20 @@ async function insertSingleRow( throw new Error(`Nested create for model "${modelName}" did not return a row`); } - return mapStorageRowToModelFields(contract, modelName, firstRow); + return mapStorageRowToModelFields(contract, namespaceId, modelName, firstRow); } async function findRowByCriterion( scope: RuntimeScope, context: ExecutionContext, + namespaceId: string, modelName: string, criterion: Record, ): Promise | null> { const contract = context.contract; const whereExpr = shorthandToWhereExpr( context, + namespaceId, modelName, criterion as MutationUpdateInput, string>, ); @@ -626,13 +724,13 @@ async function findRowByCriterion( throw new Error(`Nested connect for model "${modelName}" requires non-empty criterion`); } - const tableName = resolveModelTableName(contract, modelName); + const tableName = resolveModelTableName(contract, namespaceId, modelName); const state: CollectionState = { ...emptyState(), filters: [whereExpr], limit: 1, }; - const compiled = compileSelect(contract, tableName, state); + const compiled = compileSelect(contract, namespaceId, tableName, state); const rows = await executeQueryPlan>(scope, compiled).toArray(); const firstRow = rows[0]; @@ -640,22 +738,23 @@ async function findRowByCriterion( return null; } - return mapStorageRowToModelFields(contract, modelName, firstRow); + return mapStorageRowToModelFields(contract, namespaceId, modelName, firstRow); } async function findFirstByFilters( scope: RuntimeScope, contract: Contract, + namespaceId: string, modelName: string, filters: readonly AnyExpression[], ): Promise | null> { - const tableName = resolveModelTableName(contract, modelName); + const tableName = resolveModelTableName(contract, namespaceId, modelName); const state: CollectionState = { ...emptyState(), filters, limit: 1, }; - const compiled = compileSelect(contract, tableName, state); + const compiled = compileSelect(contract, namespaceId, tableName, state); const rows = await executeQueryPlan>(scope, compiled).toArray(); const firstRow = rows[0]; @@ -663,17 +762,18 @@ async function findFirstByFilters( return null; } - return mapStorageRowToModelFields(contract, modelName, firstRow); + return mapStorageRowToModelFields(contract, namespaceId, modelName, firstRow); } async function executeUpdateCount( scope: RuntimeScope, contract: Contract, + namespaceId: string, tableName: string, setValues: Record, filters: readonly AnyExpression[], ): Promise { - const compiled = compileUpdateCount(contract, tableName, setValues, filters); + const compiled = compileUpdateCount(contract, namespaceId, tableName, setValues, filters); await executeQueryPlan>(scope, compiled).toArray(); } @@ -681,6 +781,7 @@ const relationDefsCache = new WeakMap> function getRelationDefinitions( contract: Contract, + namespaceId: string, modelName: string, ): RelationDefinition[] { let perContract = relationDefsCache.get(contract); @@ -688,30 +789,39 @@ function getRelationDefinitions( perContract = new Map(); relationDefsCache.set(contract, perContract); } - const cached = perContract.get(modelName); + const cacheKey = `${namespaceId}\u0000${modelName}`; + const cached = perContract.get(cacheKey); if (cached) return cached; - const relations = resolveModelRelations(contract, modelName); + // The base model's relations resolve within its namespace; relation + // targets resolve within the target model's namespace (`relation.toNamespace`, + // carried by the cross-reference) so a cross-namespace relation does not + // fall back to the default/first-match path. + const relations = resolveModelRelations(contract, namespaceId, modelName); const definitions = Object.entries(relations).map(([relationName, relation]) => ({ relationName, relatedModelName: relation.to, - relatedTableName: resolveModelTableName(contract, relation.to), + relatedNamespaceId: relation.toNamespace, + relatedTableName: resolveModelTableName(contract, relation.toNamespace, relation.to), cardinality: relation.cardinality, - localColumns: relation.on.localFields.map((f) => resolveFieldToColumn(contract, modelName, f)), + localColumns: relation.on.localFields.map((f) => + resolveFieldToColumn(contract, namespaceId, modelName, f), + ), targetColumns: relation.on.targetFields.map((f) => - resolveFieldToColumn(contract, relation.to, f), + resolveFieldToColumn(contract, relation.toNamespace, relation.to, f), ), })); - perContract.set(modelName, definitions); + perContract.set(cacheKey, definitions); return definitions; } function toFieldName( contract: Contract, + namespaceId: string, modelName: string, columnName: string, ): string { - const columnToField = getColumnToFieldMap(contract, modelName); + const columnToField = getColumnToFieldMap(contract, namespaceId, modelName); return columnToField[columnName] ?? columnName; } diff --git a/packages/3-extensions/sql-orm-client/src/orm.ts b/packages/3-extensions/sql-orm-client/src/orm.ts index 5e942d1bb2..1185e279e9 100644 --- a/packages/3-extensions/sql-orm-client/src/orm.ts +++ b/packages/3-extensions/sql-orm-client/src/orm.ts @@ -1,8 +1,17 @@ -import { type Contract, domainModelsAtDefaultNamespace } from '@prisma-next/contract/types'; +import { + type Contract, + domainModelsAtDefaultNamespace, + soleDomainNamespaceId, +} from '@prisma-next/contract/types'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { blindCast } from '@prisma-next/utils/casts'; import { Collection } from './collection'; -import { domainModelNames } from './storage-resolution'; +import { + domainModelNames, + domainModelNamesInNamespace, + domainModelTableInNamespace, +} from './storage-resolution'; import type { CollectionContext, CollectionModelName, @@ -48,10 +57,36 @@ type ModelCollectionMap< [K in ModelNames]: ModelCollection; }; +type NamespaceModelNames< + TContract extends Contract, + NsId extends keyof TContract['domain']['namespaces'], +> = keyof TContract['domain']['namespaces'][NsId]['models'] & string & ModelNames; + +// The model collections of a single domain namespace, keyed by bare model +// name. Lets callers reach a model by its namespace coordinate +// (`orm..`) when the same bare name is declared in more than one +// namespace. +export type OrmNamespace< + TContract extends Contract, + Collections extends Partial>, + NsId extends keyof TContract['domain']['namespaces'], +> = { + [K in NamespaceModelNames]: ModelCollection; +}; + +type NamespacedClientMap< + TContract extends Contract, + Collections extends Partial>, +> = { + [Ns in keyof TContract['domain']['namespaces']]: OrmNamespace; +}; + +// Additive intersection: the flat by-bare-name surface retained alongside a +// per-namespace facet keyed by domain namespace id. type OrmClient< TContract extends Contract, Collections extends Partial>, -> = ModelCollectionMap; +> = ModelCollectionMap & NamespacedClientMap; export function orm< TContract extends Contract, @@ -62,10 +97,79 @@ export function orm< const ctx: CollectionContext = { runtime, context }; const modelNames = new Set(domainModelNames(contract)); const collectionRegistry = createCollectionRegistry(contract, collections); - const cache = new Map< - ModelNames, - Collection - >(); + + type AnyCollection = Collection; + + function buildCollection( + namespaceId: string, + modelName: string, + tableName?: string, + ): AnyCollection { + const CollectionClass = collectionRegistry.get(modelName) ?? Collection; + const CollectionCtor = blindCast< + new ( + ctx: CollectionContext, + modelName: string, + options?: Record, + ) => AnyCollection, + 'a registered collection class is a Collection subclass constructor' + >(CollectionClass); + return new CollectionCtor(ctx, modelName, { + registry: collectionRegistry, + namespaceId, + ...(tableName !== undefined ? { tableName } : {}), + }); + } + + const flatCache = new Map(); + const namespaceFacets = new Map(); + + function flatCollection(modelName: string): AnyCollection { + const cached = flatCache.get(modelName); + if (cached) { + return cached; + } + const collection = buildCollection(soleDomainNamespaceId(contract.domain), modelName); + flatCache.set(modelName, collection); + return collection; + } + + function namespaceFacet(namespaceId: string): object { + const cached = namespaceFacets.get(namespaceId); + if (cached) { + return cached; + } + const facetModelNames = new Set(domainModelNamesInNamespace(contract, namespaceId)); + const facetCache = new Map(); + const facet = new Proxy( + {}, + { + get(_facetTarget, modelProp: string | symbol): unknown { + if (typeof modelProp !== 'string') { + return undefined; + } + if (!facetModelNames.has(modelProp)) { + throw new Error( + `No model '${modelProp}' in namespace '${namespaceId}'. Available models: ${[...facetModelNames].join(', ')}`, + ); + } + const hit = facetCache.get(modelProp); + if (hit) { + return hit; + } + const collection = buildCollection( + namespaceId, + modelProp, + domainModelTableInNamespace(contract, namespaceId, modelProp), + ); + facetCache.set(modelProp, collection); + return collection; + }, + }, + ); + namespaceFacets.set(namespaceId, facet); + return facet; + } return new Proxy({} as OrmClient, { get(_target, prop: string | symbol): unknown { @@ -73,34 +177,17 @@ export function orm< return undefined; } + if (Object.hasOwn(contract.domain.namespaces, prop)) { + return namespaceFacet(prop); + } + if (!modelNames.has(prop)) { throw new Error( `No model found for '${prop}'. Available models: ${[...modelNames].join(', ')}`, ); } - const modelName = prop as ModelNames; - - const cached = cache.get(modelName); - if (cached) { - return cached; - } - - const CollectionClass = - collectionRegistry.get(modelName) ?? (Collection as unknown as AnyCollectionClass); - const CollectionCtor = CollectionClass as unknown as new ( - ctx: CollectionContext, - modelName: string, - options?: Record, - ) => Collection; - const collection = new CollectionCtor(ctx, modelName, { - registry: collectionRegistry, - }) as ModelCollection>; - cache.set( - modelName, - collection as unknown as Collection, - ); - return collection; + return flatCollection(prop); }, }); } @@ -108,11 +195,8 @@ export function orm< function createCollectionRegistry< TContract extends Contract, Collections extends Partial>, ->( - contract: TContract, - collections: Collections | undefined, -): Map, AnyCollectionClass> { - const registry = new Map, AnyCollectionClass>(); +>(contract: TContract, collections: Collections | undefined): Map { + const registry = new Map(); if (!collections) { return registry; } @@ -132,7 +216,7 @@ function createCollectionRegistry< `No model found for custom collection '${key}'. Available models: ${Object.keys(models).join(', ')}`, ); } - registry.set(key as ModelNames, collectionClass as AnyCollectionClass); + registry.set(key, collectionClass); } return registry; diff --git a/packages/3-extensions/sql-orm-client/src/query-plan-aggregate.ts b/packages/3-extensions/sql-orm-client/src/query-plan-aggregate.ts index b12c8133a4..03e4082a94 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan-aggregate.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan-aggregate.ts @@ -22,6 +22,7 @@ import { combineWhereExprs } from './where-utils'; function toAggregateProjection( contract: Contract, + namespaceId: string, tableName: string, selector: AggregateSelector, ): { expr: AggregateExpr; codec: CodecRef | undefined } { @@ -40,7 +41,12 @@ function toAggregateProjection( // sum widens (int4 → int8 in Postgres) and avg → numeric; both need // target+input-aware mapping that doesn't exist yet, so leave unstamped. if (selector.fn === 'min' || selector.fn === 'max') { - const codec = codecRefForStorageColumn(contract.storage, tableName, selector.column); + const codec = codecRefForStorageColumn( + contract.storage, + namespaceId, + tableName, + selector.column, + ); return { expr, codec }; } return { expr, codec: undefined }; @@ -127,6 +133,7 @@ function validateGroupedHavingExpr(expr: AnyExpression): AnyExpression { export function compileAggregate( contract: Contract, + namespaceId: string, tableName: string, filters: readonly AnyExpression[], aggregateSpec: Record>, @@ -137,10 +144,12 @@ export function compileAggregate( } const projection: ProjectionItem[] = entries.map(([alias, selector]) => { - const { expr, codec } = toAggregateProjection(contract, tableName, selector); + const { expr, codec } = toAggregateProjection(contract, namespaceId, tableName, selector); return ProjectionItem.of(alias, expr, codec); }); - let ast = SelectAst.from(tableSourceForContract(contract, tableName)).withProjection(projection); + let ast = SelectAst.from(tableSourceForContract(contract, namespaceId, tableName)).withProjection( + projection, + ); const where = combineWhereExprs(filters); if (where) { ast = ast.withWhere(where); @@ -152,6 +161,7 @@ export function compileAggregate( export function compileGroupedAggregate( contract: Contract, + namespaceId: string, tableName: string, filters: readonly AnyExpression[], groupByColumns: readonly string[], @@ -172,16 +182,16 @@ export function compileGroupedAggregate( ProjectionItem.of( column, ColumnRef.of(tableName, column), - codecRefForStorageColumn(contract.storage, tableName, column), + codecRefForStorageColumn(contract.storage, namespaceId, tableName, column), ), ), ...entries.map(([alias, selector]) => { - const { expr, codec } = toAggregateProjection(contract, tableName, selector); + const { expr, codec } = toAggregateProjection(contract, namespaceId, tableName, selector); return ProjectionItem.of(alias, expr, codec); }), ]; - let ast = SelectAst.from(tableSourceForContract(contract, tableName)) + let ast = SelectAst.from(tableSourceForContract(contract, namespaceId, tableName)) .withProjection(projection) .withGroupBy(groupByColumns.map((column) => ColumnRef.of(tableName, column))); const where = combineWhereExprs(filters); diff --git a/packages/3-extensions/sql-orm-client/src/query-plan-meta.ts b/packages/3-extensions/sql-orm-client/src/query-plan-meta.ts index 5333b01361..f40edd0ca6 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan-meta.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan-meta.ts @@ -14,10 +14,19 @@ export function deriveParamsFromAst(ast: AnyQueryAst): { }; } -export function resolveTableColumns(contract: Contract, tableName: string): string[] { +export function resolveTableColumns( + contract: Contract, + namespaceId: string, + tableName: string, +): string[] { try { - return Object.keys(storageTableForContract(contract, tableName).columns); - } catch { + return Object.keys(storageTableForContract(contract, namespaceId, tableName).columns); + } catch (error) { + // Surface the ambiguous-bare-name fail-fast rather than masking it as an + // unknown table. + if (error instanceof Error && error.message.includes('ambiguous')) { + throw error; + } throw new Error(`Unknown table "${tableName}" in SQL ORM query planner`); } } diff --git a/packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts b/packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts index 38cb847f4f..eaf8d8bcfc 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan-mutations.ts @@ -20,25 +20,27 @@ import { combineWhereExprs } from './where-utils'; function buildReturningColumns( contract: Contract, + namespaceId: string, tableName: string, returningColumns: readonly string[] | undefined, ): ReadonlyArray { const columns = returningColumns && returningColumns.length > 0 ? [...returningColumns] - : resolveTableColumns(contract, tableName); + : resolveTableColumns(contract, namespaceId, tableName); return columns.map((column) => ProjectionItem.of( column, ColumnRef.of(tableName, column), - codecRefForStorageColumn(contract.storage, tableName, column), + codecRefForStorageColumn(contract.storage, namespaceId, tableName, column), ), ); } function toParamAssignments( contract: Contract, + namespaceId: string, tableName: string, values: Record, ): { @@ -46,13 +48,13 @@ function toParamAssignments( } { const assignments: Record = {}; - const table = storageTableForContract(contract, tableName); + const table = storageTableForContract(contract, namespaceId, tableName); for (const [column, value] of Object.entries(values)) { if (!table.columns[column]) { throw new Error(`Unknown column "${column}" in table "${tableName}"`); } - const codec = codecRefForStorageColumn(contract.storage, tableName, column); + const codec = codecRefForStorageColumn(contract.storage, namespaceId, tableName, column); assignments[column] = ParamRef.of(value, { name: column, ...ifDefined('codec', codec), @@ -64,6 +66,7 @@ function toParamAssignments( function normalizeInsertRows( contract: Contract, + namespaceId: string, tableName: string, rows: readonly Record[], ): { @@ -86,7 +89,7 @@ function normalizeInsertRows( } } - const table = storageTableForContract(contract, tableName); + const table = storageTableForContract(contract, namespaceId, tableName); const normalizedRows = rows.map((row) => { if (orderedColumns.length === 0) { @@ -99,7 +102,7 @@ function normalizeInsertRows( if (!table.columns[column]) { throw new Error(`Unknown column "${column}" in table "${tableName}"`); } - const codec = codecRefForStorageColumn(contract.storage, tableName, column); + const codec = codecRefForStorageColumn(contract.storage, namespaceId, tableName, column); normalizedRow[column] = ParamRef.of(row[column], { name: column, ...ifDefined('codec', codec), @@ -116,25 +119,29 @@ function normalizeInsertRows( export function compileInsertReturning( contract: Contract, + namespaceId: string, tableName: string, rows: readonly Record[], returningColumns: readonly string[] | undefined, ): SqlQueryPlan> { - const { rows: normalizedRows } = normalizeInsertRows(contract, tableName, rows); - const ast = InsertAst.into(tableSourceForContract(contract, tableName)) + const { rows: normalizedRows } = normalizeInsertRows(contract, namespaceId, tableName, rows); + const ast = InsertAst.into(tableSourceForContract(contract, namespaceId, tableName)) .withRows(normalizedRows) - .withReturning(buildReturningColumns(contract, tableName, returningColumns)); + .withReturning(buildReturningColumns(contract, namespaceId, tableName, returningColumns)); const { params } = deriveParamsFromAst(ast); return buildOrmQueryPlan(contract, ast, params); } export function compileInsertCount( contract: Contract, + namespaceId: string, tableName: string, rows: readonly Record[], ): SqlQueryPlan> { - const { rows: normalizedRows } = normalizeInsertRows(contract, tableName, rows); - const ast = InsertAst.into(tableSourceForContract(contract, tableName)).withRows(normalizedRows); + const { rows: normalizedRows } = normalizeInsertRows(contract, namespaceId, tableName, rows); + const ast = InsertAst.into(tableSourceForContract(contract, namespaceId, tableName)).withRows( + normalizedRows, + ); const { params } = deriveParamsFromAst(ast); return buildOrmQueryPlan(contract, ast, params); } @@ -179,6 +186,7 @@ function groupRowsByColumnSignature( export function compileInsertReturningSplit( contract: Contract, + namespaceId: string, tableName: string, rows: readonly Record[], returningColumns: readonly string[] | undefined, @@ -187,12 +195,13 @@ export function compileInsertReturningSplit( throw new Error('create() requires at least one row'); } return groupRowsByColumnSignature(rows).map((group) => - compileInsertReturning(contract, tableName, group, returningColumns), + compileInsertReturning(contract, namespaceId, tableName, group, returningColumns), ); } export function compileInsertCountSplit( contract: Contract, + namespaceId: string, tableName: string, rows: readonly Record[], ): ReadonlyArray>> { @@ -200,22 +209,23 @@ export function compileInsertCountSplit( throw new Error('createCount() requires at least one row'); } return groupRowsByColumnSignature(rows).map((group) => - compileInsertCount(contract, tableName, group), + compileInsertCount(contract, namespaceId, tableName, group), ); } export function compileUpsertReturning( contract: Contract, + namespaceId: string, tableName: string, createValues: Record, updateValues: Record, conflictColumns: readonly string[], returningColumns: readonly string[] | undefined, ): SqlQueryPlan> { - const createAssignments = toParamAssignments(contract, tableName, createValues); + const createAssignments = toParamAssignments(contract, namespaceId, tableName, createValues); const hasUpdateValues = Object.keys(updateValues).length > 0; const updateAssignments = hasUpdateValues - ? toParamAssignments(contract, tableName, updateValues) + ? toParamAssignments(contract, namespaceId, tableName, updateValues) : undefined; const onConflict = updateAssignments ? InsertOnConflict.on( @@ -225,10 +235,10 @@ export function compileUpsertReturning( conflictColumns.map((column) => ColumnRef.of(tableName, column)), ).doNothing(); - const ast = InsertAst.into(tableSourceForContract(contract, tableName)) + const ast = InsertAst.into(tableSourceForContract(contract, namespaceId, tableName)) .withRows([createAssignments.assignments]) .withOnConflict(onConflict) - .withReturning(buildReturningColumns(contract, tableName, returningColumns)); + .withReturning(buildReturningColumns(contract, namespaceId, tableName, returningColumns)); const { params } = deriveParamsFromAst(ast); return buildOrmQueryPlan(contract, ast, params); @@ -236,16 +246,17 @@ export function compileUpsertReturning( export function compileUpdateReturning( contract: Contract, + namespaceId: string, tableName: string, setValues: Record, filters: readonly AnyExpression[], returningColumns: readonly string[] | undefined, ): SqlQueryPlan> { const where = combineWhereExprs(filters); - const { assignments } = toParamAssignments(contract, tableName, setValues); - let ast = UpdateAst.table(tableSourceForContract(contract, tableName)) + const { assignments } = toParamAssignments(contract, namespaceId, tableName, setValues); + let ast = UpdateAst.table(tableSourceForContract(contract, namespaceId, tableName)) .withSet(assignments) - .withReturning(buildReturningColumns(contract, tableName, returningColumns)); + .withReturning(buildReturningColumns(contract, namespaceId, tableName, returningColumns)); if (where) { ast = ast.withWhere(where); } @@ -255,13 +266,16 @@ export function compileUpdateReturning( export function compileUpdateCount( contract: Contract, + namespaceId: string, tableName: string, setValues: Record, filters: readonly AnyExpression[], ): SqlQueryPlan> { const where = combineWhereExprs(filters); - const { assignments } = toParamAssignments(contract, tableName, setValues); - let ast = UpdateAst.table(tableSourceForContract(contract, tableName)).withSet(assignments); + const { assignments } = toParamAssignments(contract, namespaceId, tableName, setValues); + let ast = UpdateAst.table(tableSourceForContract(contract, namespaceId, tableName)).withSet( + assignments, + ); if (where) { ast = ast.withWhere(where); } @@ -271,13 +285,14 @@ export function compileUpdateCount( export function compileDeleteReturning( contract: Contract, + namespaceId: string, tableName: string, filters: readonly AnyExpression[], returningColumns: readonly string[] | undefined, ): SqlQueryPlan> { const where = combineWhereExprs(filters); - let ast = DeleteAst.from(tableSourceForContract(contract, tableName)).withReturning( - buildReturningColumns(contract, tableName, returningColumns), + let ast = DeleteAst.from(tableSourceForContract(contract, namespaceId, tableName)).withReturning( + buildReturningColumns(contract, namespaceId, tableName, returningColumns), ); if (where) { ast = ast.withWhere(where); @@ -288,11 +303,12 @@ export function compileDeleteReturning( export function compileDeleteCount( contract: Contract, + namespaceId: string, tableName: string, filters: readonly AnyExpression[], ): SqlQueryPlan> { const where = combineWhereExprs(filters); - let ast = DeleteAst.from(tableSourceForContract(contract, tableName)); + let ast = DeleteAst.from(tableSourceForContract(contract, namespaceId, tableName)); if (where) { ast = ast.withWhere(where); } diff --git a/packages/3-extensions/sql-orm-client/src/query-plan-select.ts b/packages/3-extensions/sql-orm-client/src/query-plan-select.ts index 4a5d5acefe..f342107afa 100644 --- a/packages/3-extensions/sql-orm-client/src/query-plan-select.ts +++ b/packages/3-extensions/sql-orm-client/src/query-plan-select.ts @@ -46,6 +46,7 @@ type CursorOrderEntry = { function buildProjection( contract: Contract, + namespaceId: string, tableName: string, selectedFields: readonly string[] | undefined, tableRef = tableName, @@ -53,13 +54,13 @@ function buildProjection( const columns = selectedFields && selectedFields.length > 0 ? [...selectedFields] - : resolveTableColumns(contract, tableName); + : resolveTableColumns(contract, namespaceId, tableName); return columns.map((column) => ProjectionItem.of( column, ColumnRef.of(tableRef, column), - codecRefForStorageColumn(contract.storage, tableName, column), + codecRefForStorageColumn(contract.storage, namespaceId, tableName, column), ), ); } @@ -162,6 +163,7 @@ function buildStateWhere( state: CollectionState, options?: { readonly filterTableName?: string; + readonly namespaceId?: string | undefined; }, ): AnyExpression | undefined { const filterTableName = options?.filterTableName; @@ -173,7 +175,9 @@ function buildStateWhere( filter.rewrite(createTableRefRemapper(filterTableName, tableName)), ) : state.filters; - const boundCursorWhere = cursorWhere ? bindWhereExpr(contract, cursorWhere) : undefined; + const boundCursorWhere = cursorWhere + ? bindWhereExpr(contract, cursorWhere, options?.namespaceId) + : undefined; const remappedCursorWhere = boundCursorWhere && filterTableName && filterTableName !== tableName ? boundCursorWhere.rewrite(createTableRefRemapper(filterTableName, tableName)) @@ -311,12 +315,21 @@ function buildChildPolymorphismJoinsAndProjection( childTableAlias: string | undefined, childTableRef: string, ): { joins: ReadonlyArray; projection: ReadonlyArray } { - const polyInfo = resolvePolymorphismInfo(contract, include.relatedModelName); + const polyInfo = resolvePolymorphismInfo( + contract, + include.relatedNamespaceId, + include.relatedModelName, + ); if (!polyInfo || polyInfo.mtiVariants.length === 0) { return { joins: [], projection: [] }; } - const { joins, projection } = buildMtiJoins(contract, polyInfo, include.nested.variantName); + const { joins, projection } = buildMtiJoins( + contract, + include.relatedNamespaceId, + polyInfo, + include.nested.variantName, + ); if (!childTableAlias) { return { joins, projection }; } @@ -362,6 +375,7 @@ function buildIncludeChildRowsSelect( ); const childWhere = buildStateWhere(contract, childTableRef, childState, { filterTableName: include.relatedTableName, + namespaceId: include.relatedNamespaceId, }); const joinExpr = BinaryExpr.eq( ColumnRef.of(childTableRef, include.targetColumn), @@ -400,6 +414,7 @@ function buildIncludeChildRowsSelect( const scalarProjection = buildProjection( contract, + include.relatedNamespaceId, include.relatedTableName, childState.selectedFields, childTableRef, @@ -439,7 +454,12 @@ function buildIncludeChildRowsSelect( ]; let childRows = SelectAst.from( - tableSourceForContract(contract, include.relatedTableName, childTableAlias), + tableSourceForContract( + contract, + include.relatedNamespaceId, + include.relatedTableName, + childTableAlias, + ), ) .withProjection([...childProjection, ...hiddenOrderProjection]) .withWhere(whereExpr); @@ -562,6 +582,7 @@ function buildDistinctNonLeafChildRowsSelect(options: { // the projection. const innerScalarProjection = buildProjection( contract, + include.relatedNamespaceId, include.relatedTableName, selectedForQuery, childTableRef, @@ -580,7 +601,12 @@ function buildDistinctNonLeafChildRowsSelect(options: { childTableRef, ); let baseInner = SelectAst.from( - tableSourceForContract(contract, include.relatedTableName, childTableAlias), + tableSourceForContract( + contract, + include.relatedNamespaceId, + include.relatedTableName, + childTableAlias, + ), ) .withProjection([ ...innerScalarProjection, @@ -637,6 +663,7 @@ function buildDistinctNonLeafChildRowsSelect(options: { // the underlying table. const outerScalarProjection = buildProjection( contract, + include.relatedNamespaceId, include.relatedTableName, childState.selectedFields, distinctAlias, @@ -722,6 +749,7 @@ function buildIncludeChildScalarSelect( ); const childWhere = buildStateWhere(contract, childTableRef, state, { filterTableName: include.relatedTableName, + namespaceId: include.relatedNamespaceId, }); const whereExpr = childWhere ? AndExpr.of([joinExpr, childWhere]) : joinExpr; @@ -748,7 +776,12 @@ function buildIncludeChildScalarSelect( JsonObjectExpr.entry('value', aggregateExpr), ]); return SelectAst.from( - tableSourceForContract(contract, include.relatedTableName, childTableAlias), + tableSourceForContract( + contract, + include.relatedNamespaceId, + include.relatedTableName, + childTableAlias, + ), ) .withProjection([ProjectionItem.of(include.relationName, jsonObjectExpr)]) .withWhere(whereExpr); @@ -787,7 +820,12 @@ function buildIncludeChildScalarSelect( ]; let inner = SelectAst.from( - tableSourceForContract(contract, include.relatedTableName, childTableAlias), + tableSourceForContract( + contract, + include.relatedNamespaceId, + include.relatedTableName, + childTableAlias, + ), ) .withProjection(innerProjection) .withWhere(whereExpr); @@ -1028,11 +1066,19 @@ function buildSelectAst( readonly joins?: ReadonlyArray; readonly includeProjection?: ReadonlyArray; readonly where?: AnyExpression; - } = {}, + readonly namespaceId: string; + }, ): SelectAst { - const scalarProjection = buildProjection(contract, tableName, state.selectedFields); + const namespaceId = options.namespaceId; + const scalarProjection = buildProjection( + contract, + namespaceId, + tableName, + state.selectedFields, + tableName, + ); const projection = [...scalarProjection, ...(options.includeProjection ?? [])]; - const where = options.where ?? buildStateWhere(contract, tableName, state); + const where = options.where ?? buildStateWhere(contract, tableName, state, { namespaceId }); // When `.distinct(cols)` is set, wrap the table source in a // ROW_NUMBER-based dedup subquery aliased to the original `tableName`. @@ -1050,9 +1096,9 @@ function buildSelectAst( const fromSource: AnyFromSource = usesRowNumberDistinct ? DerivedTableSource.as( tableName, - buildTopLevelDistinctRankedInner(contract, tableName, state, where), + buildTopLevelDistinctRankedInner(contract, namespaceId, tableName, state, where), ) - : tableSourceForContract(contract, tableName); + : tableSourceForContract(contract, namespaceId, tableName); let ast = SelectAst.from(fromSource).withProjection(projection); if (usesRowNumberDistinct) { @@ -1088,6 +1134,7 @@ function buildSelectAst( function buildTopLevelDistinctRankedInner( contract: Contract, + namespaceId: string, tableName: string, state: CollectionState, where: AnyExpression | undefined, @@ -1099,7 +1146,7 @@ function buildTopLevelDistinctRankedInner( // Project every column of the underlying table so outer references // (projection, joins, includes' correlations, orderBy) resolve // through the derived-subquery alias. - const allCols = resolveTableColumns(contract, tableName); + const allCols = resolveTableColumns(contract, namespaceId, tableName); const allColsProjection = allCols.map((column) => ProjectionItem.of(column, ColumnRef.of(tableName, column)), ); @@ -1109,7 +1156,9 @@ function buildTopLevelDistinctRankedInner( ? state.orderBy : distinctColumnRefs.map((expr) => OrderByItem.asc(expr)); - let inner = SelectAst.from(tableSourceForContract(contract, tableName)).withProjection([ + let inner = SelectAst.from( + tableSourceForContract(contract, namespaceId, tableName), + ).withProjection([ ...allColsProjection, ProjectionItem.of( '__prisma_distinct_rn', @@ -1127,12 +1176,13 @@ function buildTopLevelDistinctRankedInner( function buildMtiJoins( contract: Contract, + namespaceId: string, polyInfo: PolymorphismInfo, variantName: string | undefined, ): { joins: JoinAst[]; projection: ProjectionItem[] } { const joins: JoinAst[] = []; const projection: ProjectionItem[] = []; - const pkColumn = resolvePrimaryKeyColumn(contract, polyInfo.baseTable); + const pkColumn = resolvePrimaryKeyColumn(contract, namespaceId, polyInfo.baseTable); const variantsToJoin = variantName ? polyInfo.mtiVariants.filter((v) => v.modelName === variantName) @@ -1146,11 +1196,11 @@ function buildMtiJoins( ); const join = joinType === 'inner' - ? JoinAst.inner(tableSourceForContract(contract, variant.table), joinOn) - : JoinAst.left(tableSourceForContract(contract, variant.table), joinOn); + ? JoinAst.inner(tableSourceForContract(contract, namespaceId, variant.table), joinOn) + : JoinAst.left(tableSourceForContract(contract, namespaceId, variant.table), joinOn); joins.push(join); - const variantColumns = resolveTableColumns(contract, variant.table); + const variantColumns = resolveTableColumns(contract, namespaceId, variant.table); for (const col of variantColumns) { if (col === pkColumn) continue; const alias = `${variant.table}__${col}`; @@ -1158,7 +1208,7 @@ function buildMtiJoins( ProjectionItem.of( alias, ColumnRef.of(variant.table, col), - codecRefForStorageColumn(contract.storage, variant.table, col), + codecRefForStorageColumn(contract.storage, namespaceId, variant.table, col), ), ); } @@ -1169,14 +1219,17 @@ function buildMtiJoins( export function compileSelect( contract: Contract, + namespaceId: string, tableName: string, state: CollectionState, modelName?: string, ): SqlQueryPlan> { - const polyInfo = modelName ? resolvePolymorphismInfo(contract, modelName) : undefined; + const polyInfo = modelName + ? resolvePolymorphismInfo(contract, namespaceId, modelName) + : undefined; const mtiArtifacts = polyInfo && polyInfo.mtiVariants.length > 0 - ? buildMtiJoins(contract, polyInfo, state.variantName) + ? buildMtiJoins(contract, namespaceId, polyInfo, state.variantName) : undefined; const ast = buildSelectAst( @@ -1187,8 +1240,9 @@ export function compileSelect( ? { joins: mtiArtifacts.joins, includeProjection: mtiArtifacts.projection, + namespaceId, } - : undefined, + : { namespaceId }, ); const { params } = deriveParamsFromAst(ast); @@ -1197,17 +1251,20 @@ export function compileSelect( export function compileSelectWithIncludes( contract: Contract, + namespaceId: string, tableName: string, state: CollectionState, modelName?: string, ): SqlQueryPlan> { const includeJoins: JoinAst[] = []; const includeProjection: ProjectionItem[] = []; - const topLevelWhere = buildStateWhere(contract, tableName, state); + const topLevelWhere = buildStateWhere(contract, tableName, state, { namespaceId }); - const polyInfo = modelName ? resolvePolymorphismInfo(contract, modelName) : undefined; + const polyInfo = modelName + ? resolvePolymorphismInfo(contract, namespaceId, modelName) + : undefined; if (polyInfo && polyInfo.mtiVariants.length > 0) { - const mtiArtifacts = buildMtiJoins(contract, polyInfo, state.variantName); + const mtiArtifacts = buildMtiJoins(contract, namespaceId, polyInfo, state.variantName); includeJoins.push(...mtiArtifacts.joins); includeProjection.push(...mtiArtifacts.projection); } @@ -1227,6 +1284,7 @@ export function compileSelectWithIncludes( { joins: includeJoins, includeProjection, + namespaceId, ...ifDefined('where', topLevelWhere), }, ); diff --git a/packages/3-extensions/sql-orm-client/src/storage-resolution.ts b/packages/3-extensions/sql-orm-client/src/storage-resolution.ts index c5685809e8..c79af0acd5 100644 --- a/packages/3-extensions/sql-orm-client/src/storage-resolution.ts +++ b/packages/3-extensions/sql-orm-client/src/storage-resolution.ts @@ -1,8 +1,4 @@ -import { - type Contract, - type ResolvedDomainModel, - resolveDomainModel, -} from '@prisma-next/contract/types'; +import type { Contract } from '@prisma-next/contract/types'; import { type ResolvedStorageTable, resolveStorageTable, @@ -10,20 +6,22 @@ import { import type { SqlStorage, StorageTable } from '@prisma-next/sql-contract/types'; import { TableSource } from '@prisma-next/sql-relational-core/ast'; -export type { ResolvedDomainModel, ResolvedStorageTable }; +export type { ResolvedStorageTable }; export function resolveTableForContract( contract: Contract, + namespaceId: string, tableName: string, ): ResolvedStorageTable | undefined { - return resolveStorageTable(contract.storage, tableName); + return resolveStorageTable(contract.storage, tableName, namespaceId); } export function requireStorageTableForContract( contract: Contract, + namespaceId: string, tableName: string, ): ResolvedStorageTable { - const resolved = resolveTableForContract(contract, tableName); + const resolved = resolveTableForContract(contract, namespaceId, tableName); if (resolved === undefined) { throw new Error(`Unknown table "${tableName}"`); } @@ -32,16 +30,10 @@ export function requireStorageTableForContract( export function storageTableForContract( contract: Contract, + namespaceId: string, tableName: string, ): StorageTable { - return requireStorageTableForContract(contract, tableName).table; -} - -export function resolveDomainModelForContract( - contract: Contract, - modelName: string, -): ResolvedDomainModel | undefined { - return resolveDomainModel(contract.domain, modelName); + return requireStorageTableForContract(contract, namespaceId, tableName).table; } export function domainModelNames(contract: Contract): string[] { @@ -54,12 +46,31 @@ export function domainModelNames(contract: Contract): string[] { return [...names]; } +export function domainModelNamesInNamespace( + contract: Contract, + namespaceId: string, +): string[] { + const namespace = contract.domain.namespaces[namespaceId]; + return namespace ? Object.keys(namespace.models) : []; +} + +export function domainModelTableInNamespace( + contract: Contract, + namespaceId: string, + modelName: string, +): string | undefined { + const model = contract.domain.namespaces[namespaceId]?.models[modelName]; + const table = model?.storage['table']; + return typeof table === 'string' ? table : undefined; +} + export function tableSourceForContract( contract: Contract, + namespaceId: string, tableName: string, alias?: string, ): TableSource { - const { namespaceId } = requireStorageTableForContract(contract, tableName); + const resolved = requireStorageTableForContract(contract, namespaceId, tableName); const effectiveAlias = alias !== undefined && alias !== tableName ? alias : undefined; - return TableSource.named(tableName, effectiveAlias, namespaceId); + return TableSource.named(tableName, effectiveAlias, resolved.namespaceId); } diff --git a/packages/3-extensions/sql-orm-client/src/types.ts b/packages/3-extensions/sql-orm-client/src/types.ts index 41f7592d62..fc5ecce255 100644 --- a/packages/3-extensions/sql-orm-client/src/types.ts +++ b/packages/3-extensions/sql-orm-client/src/types.ts @@ -53,6 +53,7 @@ export interface IncludeCombine> export interface IncludeExpr { readonly relationName: string; readonly relatedModelName: string; + readonly relatedNamespaceId: string; readonly relatedTableName: string; readonly targetColumn: string; readonly localColumn: string; diff --git a/packages/3-extensions/sql-orm-client/src/where-binding.ts b/packages/3-extensions/sql-orm-client/src/where-binding.ts index 44d7370eba..fb2c00d76a 100644 --- a/packages/3-extensions/sql-orm-client/src/where-binding.ts +++ b/packages/3-extensions/sql-orm-client/src/where-binding.ts @@ -1,4 +1,5 @@ import type { Contract } from '@prisma-next/contract/types'; +import { resolveStorageTable } from '@prisma-next/sql-contract/resolve-storage-table'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import { AndExpr, @@ -22,11 +23,23 @@ import { } from '@prisma-next/sql-relational-core/ast'; import { codecRefForStorageColumn } from '@prisma-next/sql-relational-core/codec-descriptor-registry'; -export function bindWhereExpr(contract: Contract, expr: AnyExpression): AnyExpression { - return bindWhereExprNode(contract, expr); +function namespaceCoordinateForSource(source: AnyFromSource): string | undefined { + return source.kind === 'table-source' ? source.namespaceId : undefined; } -function bindWhereExprNode(contract: Contract, expr: AnyExpression): AnyExpression { +export function bindWhereExpr( + contract: Contract, + expr: AnyExpression, + namespaceId?: string, +): AnyExpression { + return bindWhereExprNode(contract, expr, namespaceId); +} + +function bindWhereExprNode( + contract: Contract, + expr: AnyExpression, + namespaceId?: string, +): AnyExpression { return expr.accept({ columnRef(expr) { return bindExpression(contract, expr); @@ -68,13 +81,17 @@ function bindWhereExprNode(contract: Contract, expr: AnyExpression): const left = bindExpression(contract, expr.left); const bindingColumn = left.kind === 'column-ref' ? (left as ColumnRef) : undefined; - return new BinaryExpr(expr.op, left, bindComparable(contract, expr.right, bindingColumn)); + return new BinaryExpr( + expr.op, + left, + bindComparable(contract, expr.right, bindingColumn, namespaceId), + ); }, and(expr) { - return AndExpr.of(expr.exprs.map((part) => bindWhereExprNode(contract, part))); + return AndExpr.of(expr.exprs.map((part) => bindWhereExprNode(contract, part, namespaceId))); }, or(expr) { - return OrExpr.of(expr.exprs.map((part) => bindWhereExprNode(contract, part))); + return OrExpr.of(expr.exprs.map((part) => bindWhereExprNode(contract, part, namespaceId))); }, exists(expr) { return expr.notExists @@ -87,7 +104,7 @@ function bindWhereExprNode(contract: Contract, expr: AnyExpression): : NullCheckExpr.isNotNull(bindExpression(contract, expr.expr)); }, not(expr) { - return new NotExpr(bindWhereExprNode(contract, expr.expr)); + return new NotExpr(bindWhereExprNode(contract, expr.expr, namespaceId)); }, rawExpr(expr) { return expr; @@ -99,6 +116,7 @@ function bindComparable( contract: Contract, comparable: AnyExpression, bindingColumn: ColumnRef | undefined, + namespaceId?: string, ): AnyExpression { if (comparable.kind === 'param-ref' || bindingColumn === undefined) { return comparable.kind === 'param-ref' @@ -109,13 +127,15 @@ function bindComparable( } if (comparable.kind === 'literal') { - return createParamRef(contract, bindingColumn, comparable.value); + return createParamRef(contract, bindingColumn, comparable.value, namespaceId); } if (comparable.kind === 'list') { return ListExpression.of( comparable.values.map((value) => - value.kind === 'literal' ? createParamRef(contract, bindingColumn, value.value) : value, + value.kind === 'literal' + ? createParamRef(contract, bindingColumn, value.value, namespaceId) + : value, ), ); } @@ -127,14 +147,22 @@ function createParamRef( contract: Contract, columnRef: ColumnRef, value: unknown, + namespaceId?: string, ): ParamRef { - const tableInAnyNs = Object.values(contract.storage.namespaces).find( - (ns) => ns.entries.table[columnRef.table] !== undefined, - )?.entries.table[columnRef.table]; - if (!tableInAnyNs?.columns[columnRef.column]) { + // `resolveStorageTable` resolves the column's owning namespace directly when + // the coordinate is supplied, and otherwise by scanning storage — failing + // fast when a bare table name is ambiguous across namespaces rather than + // silently first-matching. + const resolved = resolveStorageTable(contract.storage, columnRef.table, namespaceId); + if (resolved === undefined || !resolved.table.columns[columnRef.column]) { throw new Error(`Unknown column "${columnRef.column}" in table "${columnRef.table}"`); } - const codec = codecRefForStorageColumn(contract.storage, columnRef.table, columnRef.column); + const codec = codecRefForStorageColumn( + contract.storage, + resolved.namespaceId, + columnRef.table, + columnRef.column, + ); return ParamRef.of(value, codec ? { codec } : undefined); } @@ -157,10 +185,11 @@ function bindOrderByItem(contract: Contract, orderItem: OrderByItem) } function bindJoin(contract: Contract, join: JoinAst): JoinAst { + const namespaceId = namespaceCoordinateForSource(join.source); return new JoinAst( join.joinType, bindFromSource(contract, join.source), - join.on.kind === 'eq-col-join-on' ? join.on : bindWhereExprNode(contract, join.on), + join.on.kind === 'eq-col-join-on' ? join.on : bindWhereExprNode(contract, join.on, namespaceId), join.lateral, ); } @@ -178,6 +207,7 @@ function bindFromSource(contract: Contract, source: AnyFromSource): } function bindSelectAst(contract: Contract, ast: SelectAst): SelectAst { + const namespaceId = namespaceCoordinateForSource(ast.from); return new SelectAst({ from: bindFromSource(contract, ast.from), joins: ast.joins?.map((join) => bindJoin(contract, join)), @@ -189,12 +219,12 @@ function bindSelectAst(contract: Contract, ast: SelectAst): SelectAs projection.codec, ), ), - where: ast.where ? bindWhereExprNode(contract, ast.where) : undefined, + where: ast.where ? bindWhereExprNode(contract, ast.where, namespaceId) : undefined, orderBy: ast.orderBy?.map((orderItem) => bindOrderByItem(contract, orderItem)), distinct: ast.distinct, distinctOn: ast.distinctOn?.map((expr) => bindExpression(contract, expr)), groupBy: ast.groupBy?.map((expr) => bindExpression(contract, expr)), - having: ast.having ? bindWhereExprNode(contract, ast.having) : undefined, + having: ast.having ? bindWhereExprNode(contract, ast.having, namespaceId) : undefined, limit: ast.limit, offset: ast.offset, selectAllIntent: ast.selectAllIntent, diff --git a/packages/3-extensions/sql-orm-client/src/where-interop.ts b/packages/3-extensions/sql-orm-client/src/where-interop.ts index af5d78e31e..6c8653bf0c 100644 --- a/packages/3-extensions/sql-orm-client/src/where-interop.ts +++ b/packages/3-extensions/sql-orm-client/src/where-interop.ts @@ -6,6 +6,7 @@ import { bindWhereExpr } from './where-binding'; interface NormalizeWhereArgOptions { readonly contract?: Contract; + readonly namespaceId?: string | undefined; } export function normalizeWhereArg(arg: undefined): undefined; @@ -33,7 +34,7 @@ export function normalizeWhereArg( } if (options?.contract) { - return bindWhereExpr(options.contract, arg); + return bindWhereExpr(options.contract, arg, options.namespaceId); } return arg; } diff --git a/packages/3-extensions/sql-orm-client/test/aggregate-builder.test.ts b/packages/3-extensions/sql-orm-client/test/aggregate-builder.test.ts index ff7d61a7ba..2ae2b62582 100644 --- a/packages/3-extensions/sql-orm-client/test/aggregate-builder.test.ts +++ b/packages/3-extensions/sql-orm-client/test/aggregate-builder.test.ts @@ -6,7 +6,7 @@ describe('aggregate-builder', () => { const contract = getTestContract(); it('createAggregateBuilder() maps numeric field selectors to storage columns', () => { - const aggregate = createAggregateBuilder(contract, 'Post'); + const aggregate = createAggregateBuilder(contract, 'public', 'Post'); const numericField = 'views' as never; expect(aggregate.count()).toEqual({ @@ -36,7 +36,7 @@ describe('aggregate-builder', () => { }); it('createAggregateBuilder() falls back to field name without mapping', () => { - const aggregate = createAggregateBuilder(contract, 'UnknownModel' as never); + const aggregate = createAggregateBuilder(contract, 'public', 'UnknownModel' as never); const numericField = 'custom_metric' as never; expect(aggregate.sum(numericField)).toEqual({ diff --git a/packages/3-extensions/sql-orm-client/test/collection-column-mapping.test.ts b/packages/3-extensions/sql-orm-client/test/collection-column-mapping.test.ts index f92cc5b04b..328c9cf3ed 100644 --- a/packages/3-extensions/sql-orm-client/test/collection-column-mapping.test.ts +++ b/packages/3-extensions/sql-orm-client/test/collection-column-mapping.test.ts @@ -7,17 +7,17 @@ describe('collection-column-mapping', () => { const contract = getTestContract(); it('resolveFieldToColumn() resolves known fields and falls back for unknown fields', () => { - expect(resolveFieldToColumn(contract, 'Post', 'userId')).toBe('user_id'); - expect(resolveFieldToColumn(contract, 'Post', 'customField')).toBe('customField'); + expect(resolveFieldToColumn(contract, 'public', 'Post', 'userId')).toBe('user_id'); + expect(resolveFieldToColumn(contract, 'public', 'Post', 'customField')).toBe('customField'); }); it('mapFieldsToColumns() maps arrays by model mapping when available', () => { - expect(mapFieldsToColumns(contract, 'Post', ['id', 'userId', 'views'])).toEqual([ + expect(mapFieldsToColumns(contract, 'public', 'Post', ['id', 'userId', 'views'])).toEqual([ 'id', 'user_id', 'views', ]); - expect(mapFieldsToColumns(contract, 'UnknownModel', ['id', 'customField'])).toEqual([ + expect(mapFieldsToColumns(contract, 'public', 'UnknownModel', ['id', 'customField'])).toEqual([ 'id', 'customField', ]); @@ -25,7 +25,7 @@ describe('collection-column-mapping', () => { it('mapCursorValuesToColumns() skips undefined values and maps field names to columns', () => { expect( - mapCursorValuesToColumns(contract, 'Post', { + mapCursorValuesToColumns(contract, 'public', 'Post', { id: 1, userId: 2, views: undefined, @@ -38,7 +38,7 @@ describe('collection-column-mapping', () => { it('mapCursorValuesToColumns() falls back when model or field mapping is missing', () => { expect( - mapCursorValuesToColumns(contract, 'UnknownModel', { + mapCursorValuesToColumns(contract, 'public', 'UnknownModel', { custom: 1, }), ).toEqual({ @@ -46,7 +46,7 @@ describe('collection-column-mapping', () => { }); expect( - mapCursorValuesToColumns(contract, 'Post', { + mapCursorValuesToColumns(contract, 'public', 'Post', { unknownField: 2, }), ).toEqual({ diff --git a/packages/3-extensions/sql-orm-client/test/collection-contract.test.ts b/packages/3-extensions/sql-orm-client/test/collection-contract.test.ts index b12cf8be21..f188713a44 100644 --- a/packages/3-extensions/sql-orm-client/test/collection-contract.test.ts +++ b/packages/3-extensions/sql-orm-client/test/collection-contract.test.ts @@ -65,8 +65,9 @@ describe('collection-contract capability detection', () => { it('resolveIncludeRelation() reads relation metadata from model.relations', () => { const contract = getTestContract(); - expect(resolveIncludeRelation(contract, 'User', 'posts')).toEqual({ + expect(resolveIncludeRelation(contract, 'public', 'User', 'posts')).toEqual({ relatedModelName: 'Post', + relatedNamespaceId: 'public', relatedTableName: 'posts', targetColumn: 'user_id', localColumn: 'id', @@ -85,7 +86,9 @@ describe('collection-contract capability detection', () => { it('resolveIncludeRelation() throws for missing or malformed relations', () => { const contract = getTestContract(); - expect(() => resolveIncludeRelation(contract, 'User', 'missing')).toThrow(/not found/); + expect(() => resolveIncludeRelation(contract, 'public', 'User', 'missing')).toThrow( + /not found/, + ); const malformed = withPatchedDomainModels(contract, (models) => ({ ...models, @@ -100,7 +103,7 @@ describe('collection-contract capability detection', () => { }, })); - expect(() => resolveIncludeRelation(malformed, 'User', 'posts')).toThrow(/not found/); + expect(() => resolveIncludeRelation(malformed, 'public', 'User', 'posts')).toThrow(/not found/); }); it('resolveIncludeRelation() handles incomplete relation metadata', () => { @@ -123,7 +126,7 @@ describe('collection-contract capability detection', () => { }, })); - expect(() => resolveIncludeRelation(incompleteRelation, 'User', 'posts')).toThrow( + expect(() => resolveIncludeRelation(incompleteRelation, 'public', 'User', 'posts')).toThrow( /incomplete join metadata/, ); }); @@ -131,34 +134,33 @@ describe('collection-contract capability detection', () => { it('resolveUpsertConflictColumns() maps explicit criteria and falls back to primary key', () => { const contract = getTestContract(); - expect(resolveUpsertConflictColumns(contract, 'Post', { userId: 'x', title: 'y' })).toEqual([ - 'user_id', - 'title', - ]); - expect(resolveUpsertConflictColumns(contract, 'Post', undefined)).toEqual(['id']); - expect(resolveUpsertConflictColumns(contract, 'Post', {})).toEqual(['id']); + expect( + resolveUpsertConflictColumns(contract, 'public', 'Post', { userId: 'x', title: 'y' }), + ).toEqual(['user_id', 'title']); + expect(resolveUpsertConflictColumns(contract, 'public', 'Post', undefined)).toEqual(['id']); + expect(resolveUpsertConflictColumns(contract, 'public', 'Post', {})).toEqual(['id']); }); it('resolveUpsertConflictColumns() falls back for unmapped fields and unknown models', () => { const contract = getTestContract(); - expect(resolveUpsertConflictColumns(contract, 'Post', { unknownField: 'x' })).toEqual([ - 'unknownField', - ]); - expect(resolveUpsertConflictColumns(contract, 'UnknownModel', { custom: 1 })).toEqual([ - 'custom', - ]); + expect(resolveUpsertConflictColumns(contract, 'public', 'Post', { unknownField: 'x' })).toEqual( + ['unknownField'], + ); + expect(resolveUpsertConflictColumns(contract, 'public', 'UnknownModel', { custom: 1 })).toEqual( + ['custom'], + ); }); it('resolveModelTableName() resolves from storage.table and throws when missing', () => { const contract = getTestContract(); - expect(resolveModelTableName(contract, 'User')).toBe('users'); - expect(() => resolveModelTableName(contract, 'UnknownModel')).toThrow( - 'Model "UnknownModel" not found in contract', + expect(resolveModelTableName(contract, 'public', 'User')).toBe('users'); + expect(() => resolveModelTableName(contract, 'public', 'UnknownModel')).toThrow( + 'Model "UnknownModel" has invalid or missing storage.table in namespace "public"', ); - expect(resolvePrimaryKeyColumn(contract, 'users')).toBe('id'); - expect(resolvePrimaryKeyColumn(contract, 'unknown_table')).toBe('id'); + expect(resolvePrimaryKeyColumn(contract, 'public', 'users')).toBe('id'); + expect(resolvePrimaryKeyColumn(contract, 'public', 'unknown_table')).toBe('id'); }); it('resolveModelTableName() reads from storage.table and throws for invalid values', () => { @@ -174,7 +176,7 @@ describe('collection-contract capability detection', () => { }, })); - expect(resolveModelTableName(withStorageFallback, 'User')).toBe('users_from_storage'); + expect(resolveModelTableName(withStorageFallback, 'public', 'User')).toBe('users_from_storage'); const invalidStorageTable = withPatchedDomainModels(contract, (models) => ({ ...models, @@ -186,8 +188,8 @@ describe('collection-contract capability detection', () => { }, })); - expect(() => resolveModelTableName(invalidStorageTable, 'User')).toThrow( - 'Model "User" has invalid or missing storage.table in the contract', + expect(() => resolveModelTableName(invalidStorageTable, 'public', 'User')).toThrow( + 'Model "User" has invalid or missing storage.table in namespace "public"', ); }); @@ -252,13 +254,21 @@ describe('collection-contract capability detection', () => { it('returns primary key columns when present', () => { expect( - resolveRowIdentityColumns(buildContract({ primaryKey: { columns: ['id'] } }), 't'), + resolveRowIdentityColumns( + buildContract({ primaryKey: { columns: ['id'] } }), + '__unbound__', + 't', + ), ).toEqual(['id']); }); it('returns composite primary key columns when present', () => { expect( - resolveRowIdentityColumns(buildContract({ primaryKey: { columns: ['a', 'b'] } }), 't'), + resolveRowIdentityColumns( + buildContract({ primaryKey: { columns: ['a', 'b'] } }), + '__unbound__', + 't', + ), ).toEqual(['a', 'b']); }); @@ -266,6 +276,7 @@ describe('collection-contract capability detection', () => { expect( resolveRowIdentityColumns( buildContract({ uniques: [{ columns: ['email'] }, { columns: ['handle'] }] }), + '__unbound__', 't', ), ).toEqual(['email']); @@ -275,18 +286,23 @@ describe('collection-contract capability detection', () => { expect( resolveRowIdentityColumns( buildContract({ uniques: [{ columns: ['tenant_id', 'slug'] }] }), + '__unbound__', 't', ), ).toEqual(['tenant_id', 'slug']); }); it('returns empty array when neither primary key nor uniques are defined', () => { - expect(resolveRowIdentityColumns(buildContract({}), 't')).toEqual([]); + expect(resolveRowIdentityColumns(buildContract({}), '__unbound__', 't')).toEqual([]); }); it('returns empty array for unknown tables', () => { expect( - resolveRowIdentityColumns(buildContract({ primaryKey: { columns: ['id'] } }), 'missing'), + resolveRowIdentityColumns( + buildContract({ primaryKey: { columns: ['id'] } }), + '__unbound__', + 'missing', + ), ).toEqual([]); }); }); @@ -295,12 +311,12 @@ describe('collection-contract capability detection', () => { describe('resolvePolymorphismInfo()', () => { it('returns undefined for non-polymorphic models', () => { const contract = getTestContract(); - expect(resolvePolymorphismInfo(contract, 'User')).toBeUndefined(); + expect(resolvePolymorphismInfo(contract, 'public', 'User')).toBeUndefined(); }); it('classifies Bug as STI (same table as Task)', () => { const contract = buildMixedPolyContract(); - const info = resolvePolymorphismInfo(contract, 'Task'); + const info = resolvePolymorphismInfo(contract, 'public', 'Task'); expect(info).toBeDefined(); const bugVariant = info!.variants.get('Bug'); expect(bugVariant).toBeDefined(); @@ -311,7 +327,7 @@ describe('resolvePolymorphismInfo()', () => { it('classifies Feature as MTI (different table from Task)', () => { const contract = buildMixedPolyContract(); - const info = resolvePolymorphismInfo(contract, 'Task'); + const info = resolvePolymorphismInfo(contract, 'public', 'Task'); expect(info).toBeDefined(); const featureVariant = info!.variants.get('Feature'); expect(featureVariant).toBeDefined(); @@ -322,7 +338,7 @@ describe('resolvePolymorphismInfo()', () => { it('resolves discriminator field and column', () => { const contract = buildMixedPolyContract(); - const info = resolvePolymorphismInfo(contract, 'Task')!; + const info = resolvePolymorphismInfo(contract, 'public', 'Task')!; expect(info.discriminatorField).toBe('type'); expect(info.discriminatorColumn).toBe('type'); expect(info.baseTable).toBe('tasks'); @@ -330,29 +346,29 @@ describe('resolvePolymorphismInfo()', () => { it('populates variantsByValue keyed by discriminator value', () => { const contract = buildMixedPolyContract(); - const info = resolvePolymorphismInfo(contract, 'Task')!; + const info = resolvePolymorphismInfo(contract, 'public', 'Task')!; expect(info.variantsByValue.get('bug')?.modelName).toBe('Bug'); expect(info.variantsByValue.get('feature')?.modelName).toBe('Feature'); }); it('populates mtiVariants with only MTI variants', () => { const contract = buildMixedPolyContract(); - const info = resolvePolymorphismInfo(contract, 'Task')!; + const info = resolvePolymorphismInfo(contract, 'public', 'Task')!; expect(info.mtiVariants).toHaveLength(1); expect(info.mtiVariants[0]!.modelName).toBe('Feature'); }); it('caches results per (contract, modelName)', () => { const contract = buildMixedPolyContract(); - const first = resolvePolymorphismInfo(contract, 'Task'); - const second = resolvePolymorphismInfo(contract, 'Task'); + const first = resolvePolymorphismInfo(contract, 'public', 'Task'); + const second = resolvePolymorphismInfo(contract, 'public', 'Task'); expect(first).toBe(second); }); it('returns undefined for variant models themselves', () => { const contract = buildMixedPolyContract(); - expect(resolvePolymorphismInfo(contract, 'Bug')).toBeUndefined(); - expect(resolvePolymorphismInfo(contract, 'Feature')).toBeUndefined(); + expect(resolvePolymorphismInfo(contract, 'public', 'Bug')).toBeUndefined(); + expect(resolvePolymorphismInfo(contract, 'public', 'Feature')).toBeUndefined(); }); it('throws when a declared variant model is missing from the contract', () => { @@ -361,7 +377,7 @@ describe('resolvePolymorphismInfo()', () => { const { Bug: _removed, ...rest } = models; return rest; }); - expect(() => resolvePolymorphismInfo(withoutBug, 'Task')).toThrow( + expect(() => resolvePolymorphismInfo(withoutBug, 'public', 'Task')).toThrow( /declares variant "Bug", but that model is missing/, ); }); @@ -371,7 +387,7 @@ describe('resolveModelRelations() through descriptor', () => { it('populates through descriptor for a simple single-column M:N relation', () => { const contract = getTestContract(); - const relations = resolveModelRelations(contract, 'User'); + const relations = resolveModelRelations(contract, 'public', 'User'); expect(relations['tags']?.through).toEqual({ table: 'user_tags', parentColumns: ['user_id'], @@ -385,7 +401,7 @@ describe('resolveModelRelations() through descriptor', () => { it('populates through descriptor for a composite-key M:N junction', () => { const contract = getTestContract(); - const through = resolveModelRelations(contract, 'Project')['related']?.through; + const through = resolveModelRelations(contract, 'public', 'Project')['related']?.through; expect(through?.parentColumns).toEqual(['src_tenant_id', 'src_id']); expect(through?.childColumns).toEqual(['dst_tenant_id', 'dst_id']); expect(through?.targetColumns).toEqual(['tenant_id', 'id']); @@ -396,7 +412,7 @@ describe('resolveModelRelations() through descriptor', () => { const contract = getTestContract(); expect( - resolveModelRelations(contract, 'User')['roles']?.through?.requiredPayloadColumns, + resolveModelRelations(contract, 'public', 'User')['roles']?.through?.requiredPayloadColumns, ).toEqual(['level']); }); @@ -406,7 +422,7 @@ describe('resolveModelRelations() through descriptor', () => { // user_tags carries a nullable `note` column and a `created_at` column with a // now() default alongside its FK pair, so neither belongs in the payload. expect( - resolveModelRelations(contract, 'User')['tags']?.through?.requiredPayloadColumns, + resolveModelRelations(contract, 'public', 'User')['tags']?.through?.requiredPayloadColumns, ).toEqual([]); }); @@ -426,6 +442,6 @@ describe('resolveModelRelations() through descriptor', () => { }; }); - expect(resolveModelRelations(contract, 'User')['tags']?.through).toBeUndefined(); + expect(resolveModelRelations(contract, 'public', 'User')['tags']?.through).toBeUndefined(); }); }); diff --git a/packages/3-extensions/sql-orm-client/test/collection-dispatch.test.ts b/packages/3-extensions/sql-orm-client/test/collection-dispatch.test.ts index 0dbb903c4c..cc9f660210 100644 --- a/packages/3-extensions/sql-orm-client/test/collection-dispatch.test.ts +++ b/packages/3-extensions/sql-orm-client/test/collection-dispatch.test.ts @@ -19,12 +19,14 @@ function includeFor( parentModel: string, relationName: string, nested: CollectionState = emptyState(), + namespaceId = 'public', ): IncludeExpr { - const relation = resolveIncludeRelation(contract, parentModel, relationName); + const relation = resolveIncludeRelation(contract, namespaceId, parentModel, relationName); return { relationName, relatedModelName: relation.relatedModelName, relatedTableName: relation.relatedTableName, + relatedNamespaceId: relation.relatedNamespaceId, targetColumn: relation.targetColumn, localColumn: relation.localColumn, cardinality: relation.cardinality, @@ -98,6 +100,7 @@ describe('collection-dispatch', () => { runtime, state: collection.state, tableName: collection.tableName, + namespaceId: 'public', modelName: collection.modelName, }).toArray(); @@ -121,6 +124,7 @@ describe('collection-dispatch', () => { runtime, state: scoped.state, tableName: scoped.tableName, + namespaceId: 'public', modelName: scoped.modelName, }).toArray(); @@ -171,6 +175,7 @@ describe('collection-dispatch', () => { runtime, state: scoped.state, tableName: scoped.tableName, + namespaceId: 'public', modelName: scoped.modelName, }).toArray(); @@ -222,6 +227,7 @@ describe('collection-dispatch', () => { runtime, state: scoped.state, tableName: scoped.tableName, + namespaceId: 'public', modelName: scoped.modelName, }).toArray(); @@ -262,6 +268,7 @@ describe('collection-dispatch', () => { runtime: runtimeWithConnection, state: scoped.state, tableName: scoped.tableName, + namespaceId: 'public', modelName: scoped.modelName, }).toArray(); @@ -303,6 +310,7 @@ describe('collection-dispatch', () => { runtime, state: scoped.state, tableName: scoped.tableName, + namespaceId: 'public', modelName: scoped.modelName, }).toArray(); @@ -350,6 +358,7 @@ describe('collection-dispatch', () => { runtime, state: scoped.state, tableName: scoped.tableName, + namespaceId: 'public', modelName: scoped.modelName, }).toArray(); @@ -394,6 +403,7 @@ describe('collection-dispatch', () => { state, tableName: 'accounts', modelName: 'Account', + namespaceId: 'public', }); const members = (rows[0] as { members: Record[] }).members; @@ -434,6 +444,7 @@ describe('collection-dispatch', () => { state, tableName: 'projects_tbl', modelName: 'Project', + namespaceId: 'public', }).toArray(); const tasks = (rows[0] as { tasks: Record[] }).tasks; @@ -511,6 +522,7 @@ describe('collection-dispatch', () => { state, tableName: 'projects_tbl', modelName: 'Project', + namespaceId: 'public', }).toArray(); const tasks = (rows[0] as { tasks: Record[] }).tasks; @@ -566,6 +578,7 @@ describe('collection-dispatch', () => { state, tableName: 'projects_tbl', modelName: 'Project', + namespaceId: 'public', }).toArray(); const tasks = (rows[0] as { tasks: Record[] }).tasks; diff --git a/packages/3-extensions/sql-orm-client/test/collection-fixtures.ts b/packages/3-extensions/sql-orm-client/test/collection-fixtures.ts index 20220ec389..6243f63910 100644 --- a/packages/3-extensions/sql-orm-client/test/collection-fixtures.ts +++ b/packages/3-extensions/sql-orm-client/test/collection-fixtures.ts @@ -1,4 +1,4 @@ -import type { Contract } from '@prisma-next/contract/types'; +import { type Contract, soleDomainNamespaceId } from '@prisma-next/contract/types'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; import { Collection } from '../src/collection'; @@ -30,7 +30,9 @@ export function createCollectionFor( } { const runtime = createMockRuntime(); const context = contextForContract(contract); - const collection = new Collection({ runtime, context }, modelName); + const collection = new Collection({ runtime, context }, modelName, { + namespaceId: soleDomainNamespaceId(context.contract.domain), + }); return { collection, runtime, @@ -70,7 +72,9 @@ export function createReturningCollectionWithoutDefaultInInsert( } { const runtime = createMockRuntime(); const context = contextForContract(withReturningCapability()); - const collection = new Collection({ runtime, context }, modelName); + const collection = new Collection({ runtime, context }, modelName, { + namespaceId: soleDomainNamespaceId(context.contract.domain), + }); return { collection, runtime, diff --git a/packages/3-extensions/sql-orm-client/test/collection-mutation-dispatch.test.ts b/packages/3-extensions/sql-orm-client/test/collection-mutation-dispatch.test.ts index 57542835ed..75ba4a4509 100644 --- a/packages/3-extensions/sql-orm-client/test/collection-mutation-dispatch.test.ts +++ b/packages/3-extensions/sql-orm-client/test/collection-mutation-dispatch.test.ts @@ -45,6 +45,7 @@ describe('collection-mutation-dispatch', () => { runtime, compiled: makeCompiled('insert into users ... returning *'), tableName: 'users', + namespaceId: 'public', modelName: 'User', includes: [], selectedFields: undefined, @@ -65,6 +66,7 @@ describe('collection-mutation-dispatch', () => { runtime, compiled: makeCompiled('delete from users returning *'), tableName: 'users', + namespaceId: 'public', modelName: 'User', includes: [], selectedFields: undefined, @@ -86,6 +88,7 @@ describe('collection-mutation-dispatch', () => { runtime, compiled: makeCompiled('update users set ... returning *'), tableName: 'users', + namespaceId: 'public', modelName: 'User', includes: [], selectedFields: undefined, @@ -111,6 +114,7 @@ describe('collection-mutation-dispatch', () => { runtime, plans: [makeCompiled('insert batch 1'), makeCompiled('insert batch 2')], tableName: 'users', + namespaceId: 'public', modelName: 'User', includes: [], selectedFields: undefined, @@ -135,6 +139,7 @@ describe('collection-mutation-dispatch', () => { runtime, plans: [makeCompiled('insert ...')], tableName: 'users', + namespaceId: 'public', modelName: 'User', includes: [], selectedFields: undefined, @@ -155,6 +160,7 @@ describe('collection-mutation-dispatch', () => { runtime, plans: [makeCompiled('insert batch 1'), makeCompiled('insert batch 2')], tableName: 'users', + namespaceId: 'public', modelName: 'User', includes: [], selectedFields: undefined, diff --git a/packages/3-extensions/sql-orm-client/test/collection-runtime.test.ts b/packages/3-extensions/sql-orm-client/test/collection-runtime.test.ts index cb67f5e358..e742ffd686 100644 --- a/packages/3-extensions/sql-orm-client/test/collection-runtime.test.ts +++ b/packages/3-extensions/sql-orm-client/test/collection-runtime.test.ts @@ -18,18 +18,20 @@ describe('collection-runtime', () => { it('mapStorageRowToModelFields() maps known columns and falls back otherwise', () => { expect( - mapStorageRowToModelFields(contract, 'Post', { id: 1, user_id: 2, custom: true }), + mapStorageRowToModelFields(contract, 'public', 'Post', { id: 1, user_id: 2, custom: true }), ).toEqual({ id: 1, userId: 2, custom: true, }); - expect(mapStorageRowToModelFields(contract, 'UnknownModel', { id: 1 })).toEqual({ id: 1 }); + expect(mapStorageRowToModelFields(contract, 'public', 'UnknownModel', { id: 1 })).toEqual({ + id: 1, + }); }); it('mapModelDataToStorageRow() maps fields and skips undefined values', () => { expect( - mapModelDataToStorageRow(contract, 'Post', { + mapModelDataToStorageRow(contract, 'public', 'Post', { id: 1, userId: 2, views: undefined, @@ -44,7 +46,7 @@ describe('collection-runtime', () => { it('mapModelDataToStorageRow() falls back to input keys when model mappings are missing', () => { expect( - mapModelDataToStorageRow(contract, 'UnknownModel', { + mapModelDataToStorageRow(contract, 'public', 'UnknownModel', { customField: 1, optionalField: undefined, }), @@ -55,25 +57,25 @@ describe('collection-runtime', () => { it('stripHiddenMappedFields() removes mapped fields for hidden columns', () => { const mapped = { id: 1, userId: 2, title: 'A' }; - stripHiddenMappedFields(contract, 'Post', mapped, ['user_id']); + stripHiddenMappedFields(contract, 'public', 'Post', mapped, ['user_id']); expect(mapped).toEqual({ id: 1, title: 'A' }); - stripHiddenMappedFields(contract, 'Post', mapped, []); + stripHiddenMappedFields(contract, 'public', 'Post', mapped, []); expect(mapped).toEqual({ id: 1, title: 'A' }); }); it('stripHiddenMappedFields() falls back to raw column names when mappings are missing', () => { const unknownTableMapped = { custom_col: 1 }; - stripHiddenMappedFields(contract, 'UnknownModel', unknownTableMapped, ['custom_col']); + stripHiddenMappedFields(contract, 'public', 'UnknownModel', unknownTableMapped, ['custom_col']); expect(unknownTableMapped).toEqual({}); const unknownColumnMapped = { id: 1, custom_col: 2 }; - stripHiddenMappedFields(contract, 'User', unknownColumnMapped, ['custom_col']); + stripHiddenMappedFields(contract, 'public', 'User', unknownColumnMapped, ['custom_col']); expect(unknownColumnMapped).toEqual({ id: 1 }); }); it('createRowEnvelope() retains raw and mapped values', () => { - expect(createRowEnvelope(contract, 'Post', { id: 1, user_id: 2 })).toEqual({ + expect(createRowEnvelope(contract, 'public', 'Post', { id: 1, user_id: 2 })).toEqual({ raw: { id: 1, user_id: 2 }, mapped: { id: 1, userId: 2 }, }); @@ -144,20 +146,20 @@ describe('collection-runtime', () => { describe('mapPolymorphicRow()', () => { it('maps STI Bug row: includes base + Bug fields, excludes Feature fields', () => { const contract = buildMixedPolyContract(); - const polyInfo = resolvePolymorphismInfo(contract, 'Task')!; + const polyInfo = resolvePolymorphismInfo(contract, 'public', 'Task')!; const row = { id: 1, title: 'Crash', type: 'bug', severity: 'critical' }; - const result = mapPolymorphicRow(contract, 'Task', polyInfo, row); + const result = mapPolymorphicRow(contract, 'public', 'Task', polyInfo, row); expect(result).toEqual({ id: 1, title: 'Crash', type: 'bug', severity: 'critical' }); }); it('maps STI row and strips non-matching variant columns (NULL for other STI variants)', () => { const contract = buildMixedPolyContract(); - const polyInfo = resolvePolymorphismInfo(contract, 'Task')!; + const polyInfo = resolvePolymorphismInfo(contract, 'public', 'Task')!; const row = { id: 1, title: 'Crash', type: 'bug', severity: 'critical', priority: null }; - const result = mapPolymorphicRow(contract, 'Task', polyInfo, row); + const result = mapPolymorphicRow(contract, 'public', 'Task', polyInfo, row); expect(result).toEqual({ id: 1, title: 'Crash', type: 'bug', severity: 'critical' }); expect(result).not.toHaveProperty('priority'); @@ -165,7 +167,7 @@ describe('mapPolymorphicRow()', () => { it('maps MTI Feature row: includes base + Feature fields via table-qualified aliases', () => { const contract = buildMixedPolyContract(); - const polyInfo = resolvePolymorphismInfo(contract, 'Task')!; + const polyInfo = resolvePolymorphismInfo(contract, 'public', 'Task')!; const row = { id: 2, @@ -174,7 +176,7 @@ describe('mapPolymorphicRow()', () => { severity: null, features__priority: 1, }; - const result = mapPolymorphicRow(contract, 'Task', polyInfo, row); + const result = mapPolymorphicRow(contract, 'public', 'Task', polyInfo, row); expect(result).toEqual({ id: 2, title: 'Dark mode', type: 'feature', priority: 1 }); expect(result).not.toHaveProperty('severity'); @@ -182,17 +184,17 @@ describe('mapPolymorphicRow()', () => { it('maps row with known variant using variantName override', () => { const contract = buildMixedPolyContract(); - const polyInfo = resolvePolymorphismInfo(contract, 'Task')!; + const polyInfo = resolvePolymorphismInfo(contract, 'public', 'Task')!; const row = { id: 1, title: 'Crash', type: 'bug', severity: 'high' }; - const result = mapPolymorphicRow(contract, 'Task', polyInfo, row, 'Bug'); + const result = mapPolymorphicRow(contract, 'public', 'Task', polyInfo, row, 'Bug'); expect(result).toEqual({ id: 1, title: 'Crash', type: 'bug', severity: 'high' }); }); it('falls back to base-only mapping for unknown discriminator values', () => { const contract = buildMixedPolyContract(); - const polyInfo = resolvePolymorphismInfo(contract, 'Task')!; + const polyInfo = resolvePolymorphismInfo(contract, 'public', 'Task')!; const row = { id: 3, @@ -201,7 +203,7 @@ describe('mapPolymorphicRow()', () => { severity: null, features__priority: null, }; - const result = mapPolymorphicRow(contract, 'Task', polyInfo, row); + const result = mapPolymorphicRow(contract, 'public', 'Task', polyInfo, row); expect(result).toEqual({ id: 3, title: 'Unknown', type: 'epic' }); }); @@ -226,10 +228,10 @@ describe('mapPolymorphicRow()', () => { fields: { priority: {} }, }; - const polyInfo = resolvePolymorphismInfo(contract, 'Task')!; + const polyInfo = resolvePolymorphismInfo(contract, 'public', 'Task')!; const stiRow = { id: 1, title: 'Crash', type: 'bug', severity: 'high' }; - expect(mapPolymorphicRow(contract, 'Task', polyInfo, stiRow)).toEqual({ + expect(mapPolymorphicRow(contract, 'public', 'Task', polyInfo, stiRow)).toEqual({ id: 1, title: 'Crash', type: 'bug', @@ -237,7 +239,7 @@ describe('mapPolymorphicRow()', () => { }); const mtiRow = { id: 2, title: 'Feature', type: 'feature', features__priority: 5 }; - expect(mapPolymorphicRow(contract, 'Task', polyInfo, mtiRow)).toEqual({ + expect(mapPolymorphicRow(contract, 'public', 'Task', polyInfo, mtiRow)).toEqual({ id: 2, title: 'Feature', type: 'feature', @@ -245,7 +247,7 @@ describe('mapPolymorphicRow()', () => { }); const unknownRow = { id: 3, title: 'Unknown', type: 'epic' }; - expect(mapPolymorphicRow(contract, 'Task', polyInfo, unknownRow)).toEqual({ + expect(mapPolymorphicRow(contract, 'public', 'Task', polyInfo, unknownRow)).toEqual({ id: 3, title: 'Unknown', type: 'epic', diff --git a/packages/3-extensions/sql-orm-client/test/collection-variant.test.ts b/packages/3-extensions/sql-orm-client/test/collection-variant.test.ts index fbb22bdfbd..a924f96584 100644 --- a/packages/3-extensions/sql-orm-client/test/collection-variant.test.ts +++ b/packages/3-extensions/sql-orm-client/test/collection-variant.test.ts @@ -54,7 +54,7 @@ function createPolyCollection() { const baseContext = getTestContext(); const context = { ...baseContext, contract }; const runtime = createMockRuntime(); - const collection = new Collection({ runtime, context }, 'User'); + const collection = new Collection({ runtime, context }, 'User', { namespaceId: 'public' }); return { collection, runtime, contract }; } @@ -92,7 +92,9 @@ describe('Collection.variant()', () => { it('returns unchanged collection when model has no discriminator', () => { const baseContext = getTestContext(); const runtime = createMockRuntime(); - const collection = new Collection({ runtime, context: baseContext }, 'User'); + const collection = new Collection({ runtime, context: baseContext }, 'User', { + namespaceId: 'public', + }); const result = collection.variant('Admin' as never); expect(result.state.filters).toHaveLength(0); }); @@ -184,7 +186,7 @@ function createMixedPolyCollection() { const baseContext = getTestContext(); const context = { ...baseContext, contract }; const runtime = createMockRuntime(); - const collection = new Collection({ runtime, context }, 'Task'); + const collection = new Collection({ runtime, context }, 'Task', { namespaceId: 'public' }); return { collection, runtime }; } @@ -265,7 +267,9 @@ describe('Mixed STI+MTI polymorphic query pipeline', () => { const contract = buildMixedPolyContract(); const context = { ...getTestContext(), contract }; const runtime = createMockRuntime(); - const projects = new Collection({ runtime, context }, 'Project') as unknown as PolyParent; + const projects = new Collection({ runtime, context }, 'Project', { + namespaceId: 'public', + }) as unknown as PolyParent; const refined = projects.include('tasks', (tasks) => tasks.variant('Bug')); @@ -276,7 +280,9 @@ describe('Mixed STI+MTI polymorphic query pipeline', () => { const contract = buildMixedPolyContract(); const context = { ...getTestContext(), contract }; const runtime = createMockRuntime(); - const projects = new Collection({ runtime, context }, 'Project') as unknown as PolyParent; + const projects = new Collection({ runtime, context }, 'Project', { + namespaceId: 'public', + }) as unknown as PolyParent; const included = projects.include('tasks'); @@ -289,7 +295,7 @@ function createReturningMixedPolyCollection() { const baseContext = getTestContext(); const context = { ...baseContext, contract }; const runtime = createMockRuntime(); - const collection = new Collection({ runtime, context }, 'Task'); + const collection = new Collection({ runtime, context }, 'Task', { namespaceId: 'public' }); return { collection, runtime, contract }; } @@ -442,7 +448,9 @@ describe('MTI variant create (two-INSERT orchestration)', () => { }), }; - const collection = new Collection({ runtime: txRuntime, context }, 'Task'); + const collection = new Collection({ runtime: txRuntime, context }, 'Task', { + namespaceId: 'public', + }); const narrowed = collection.variant('Feature' as never) as typeof collection; await narrowed.createAll([{ title: 'Dark mode', priority: 1 } as never]).toArray(); diff --git a/packages/3-extensions/sql-orm-client/test/cross-space-relation.test-d.ts b/packages/3-extensions/sql-orm-client/test/cross-space-relation.test-d.ts index 3dedf22e65..33f82441da 100644 --- a/packages/3-extensions/sql-orm-client/test/cross-space-relation.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/cross-space-relation.test-d.ts @@ -100,10 +100,9 @@ const runtime = createMockRuntime(); // Widen to a Collection type that uses the synthetic contract — use // `as unknown as` only to satisfy the constructor's runtime requirements; // the TYPE is what we are testing. -const profileCollection = new Collection( - { runtime, context: {} as never }, - 'Profile', -) as unknown as Collection; +const profileCollection = new Collection({ runtime, context: {} as never }, 'Profile', { + namespaceId: 'public', +}) as unknown as Collection; // Positive: a local relation can be included — must compile void profileCollection.include('posts'); diff --git a/packages/3-extensions/sql-orm-client/test/filters.test.ts b/packages/3-extensions/sql-orm-client/test/filters.test.ts index 544a4fc500..dbd8943803 100644 --- a/packages/3-extensions/sql-orm-client/test/filters.test.ts +++ b/packages/3-extensions/sql-orm-client/test/filters.test.ts @@ -29,7 +29,7 @@ describe('filters', () => { } it('and(), or(), not(), and all() use rich where objects', () => { - const user = createModelAccessor(context, 'User'); + const user = createModelAccessor(context, 'public', 'User'); const andExpr = and(user['name']!.eq('Alice'), user['email']!.neq('bob@example.com')); expect(andExpr).toEqual( @@ -74,7 +74,7 @@ describe('filters', () => { }); it('wraps scalar binary operators in NotExpr', () => { - const user = createModelAccessor(context, 'User'); + const user = createModelAccessor(context, 'public', 'User'); expect(not(user['id']!.neq(1))).toEqual( new NotExpr(BinaryExpr.neq(ColumnRef.of('users', 'id'), paramRef('users', 'id', 1))), @@ -107,7 +107,7 @@ describe('filters', () => { }); it('eq(null) / neq(null) lower to IS NULL / IS NOT NULL', () => { - const post = createModelAccessor(context, 'Post'); + const post = createModelAccessor(context, 'public', 'Post'); const userId = post['userId']! as { eq: (v: unknown) => unknown; neq: (v: unknown) => unknown }; expect(userId.eq(null)).toEqual(NullCheckExpr.isNull(ColumnRef.of('posts', 'user_id'))); @@ -115,7 +115,7 @@ describe('filters', () => { }); it('wraps like in NotExpr', () => { - const user = createModelAccessor(context, 'User'); + const user = createModelAccessor(context, 'public', 'User'); expect(not(user['name']!.like('%a%'))).toEqual( new NotExpr(BinaryExpr.like(ColumnRef.of('users', 'name'), paramRef('users', 'name', '%a%'))), @@ -123,7 +123,7 @@ describe('filters', () => { }); it('shorthandToWhereExpr() maps nulls, skips undefined, and combines multiple fields', () => { - const expr = shorthandToWhereExpr(context, 'Post', { + const expr = shorthandToWhereExpr(context, 'public', 'Post', { id: 1, userId: null, views: undefined, @@ -159,16 +159,16 @@ describe('filters', () => { }, } as unknown as typeof context; - expect(() => shorthandToWhereExpr(stubbedContext, 'User', { email: 'a@b.com' })).toThrow( - /does not support equality comparisons/, - ); + expect(() => + shorthandToWhereExpr(stubbedContext, 'public', 'User', { email: 'a@b.com' }), + ).toThrow(/does not support equality comparisons/); }); it('shorthandToWhereExpr() rejects equality-shorthand on a non-scalar field type', () => { // When `fieldType?.kind !== 'scalar'` (e.g. the field doesn't have a codec id resolvable from a scalar type), the trait array is empty and the filter throws — this models a relation-shorthand attempt through the scalar code path. - expect(() => shorthandToWhereExpr(context, 'User', { posts: 'oops' } as never)).toThrow( - /does not support equality comparisons/, - ); + expect(() => + shorthandToWhereExpr(context, 'public', 'User', { posts: 'oops' } as never), + ).toThrow(/does not support equality comparisons/); }); it('shorthandToWhereExpr() rejects equality-shorthand when no descriptor is registered for the codec', () => { @@ -181,16 +181,16 @@ describe('filters', () => { }, } as unknown as typeof context; - expect(() => shorthandToWhereExpr(stubbedContext, 'User', { email: 'a@b.com' })).toThrow( - /does not support equality comparisons/, - ); + expect(() => + shorthandToWhereExpr(stubbedContext, 'public', 'User', { email: 'a@b.com' }), + ).toThrow(/does not support equality comparisons/); }); it('shorthandToWhereExpr() supports storage and model-name fallbacks', () => { - expect(shorthandToWhereExpr(context, 'User', {})).toBeUndefined(); + expect(shorthandToWhereExpr(context, 'public', 'User', {})).toBeUndefined(); expect( - shorthandToWhereExpr(context, 'User', { + shorthandToWhereExpr(context, 'public', 'User', { email: 'alice@example.com', }), ).toEqual(BinaryExpr.eq(ColumnRef.of('users', 'email'), LiteralExpr.of('alice@example.com'))); @@ -205,9 +205,14 @@ describe('filters', () => { })); expect( - shorthandToWhereExpr({ ...context, contract: withoutStorageFields } as never, 'User', { - unknownField: null, - } as never), + shorthandToWhereExpr( + { ...context, contract: withoutStorageFields } as never, + 'public', + 'User', + { + unknownField: null, + } as never, + ), ).toEqual(NullCheckExpr.isNull(ColumnRef.of('users', 'unknownField'))); }); }); diff --git a/packages/3-extensions/sql-orm-client/test/generated-contract-types.test-d.ts b/packages/3-extensions/sql-orm-client/test/generated-contract-types.test-d.ts index 9884fc265d..e1ff0895b3 100644 --- a/packages/3-extensions/sql-orm-client/test/generated-contract-types.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/generated-contract-types.test-d.ts @@ -182,11 +182,11 @@ type StateOf = const runtime = createMockRuntime(); const context = {} as unknown as ExecutionContext; -const collection = new PostCollection({ runtime, context }, 'Post'); +const collection = new PostCollection({ runtime, context }, 'Post', { namespaceId: 'public' }); collection.forUser('user_001'); -const userCollection = new Collection({ runtime, context }, 'User'); -const postCollection = new Collection({ runtime, context }, 'Post'); +const userCollection = new Collection({ runtime, context }, 'User', { namespaceId: 'public' }); +const postCollection = new Collection({ runtime, context }, 'Post', { namespaceId: 'public' }); type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; diff --git a/packages/3-extensions/sql-orm-client/test/include-cardinality.test-d.ts b/packages/3-extensions/sql-orm-client/test/include-cardinality.test-d.ts index 0be39faef2..7f66f4686b 100644 --- a/packages/3-extensions/sql-orm-client/test/include-cardinality.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/include-cardinality.test-d.ts @@ -22,10 +22,14 @@ type Assert = T; const runtime = createMockRuntime(); const context = {} as ExecutionContext; -const userCollection = new Collection({ runtime, context }, 'User'); -const postCollection = new Collection({ runtime, context }, 'Post'); -const profileCollection = new Collection({ runtime, context }, 'Profile'); -const articleCollection = new Collection({ runtime, context }, 'Article'); +const userCollection = new Collection({ runtime, context }, 'User', { namespaceId: 'public' }); +const postCollection = new Collection({ runtime, context }, 'Post', { namespaceId: 'public' }); +const profileCollection = new Collection({ runtime, context }, 'Profile', { + namespaceId: 'public', +}); +const articleCollection = new Collection({ runtime, context }, 'Article', { + namespaceId: 'public', +}); const usersWithPosts = userCollection.include('posts'); const usersWithProfile = userCollection.include('profile'); diff --git a/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts b/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts index f14b215e8a..f0fd3e1df2 100644 --- a/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts +++ b/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts @@ -85,8 +85,8 @@ describe('createModelAccessor', () => { } it('creates scalar comparison operators and maps fields to columns', () => { - const user = createModelAccessor(context, 'User'); - const post = createModelAccessor(context, 'Post'); + const user = createModelAccessor(context, 'public', 'User'); + const post = createModelAccessor(context, 'public', 'Post'); expectBinaryParam(user['name']!.eq('Alice'), 'users', 'name', 'eq', 'Alice'); expectBinaryParam( @@ -105,7 +105,7 @@ describe('createModelAccessor', () => { }); it('creates ilike as trait-matched extension operation returning predicate', () => { - const user = createModelAccessor(context, 'User'); + const user = createModelAccessor(context, 'public', 'User'); const ilike = user['name']!.ilike; const result = ilike('%ali%'); expect(result).toBeInstanceOf(OperationExpr); @@ -115,13 +115,13 @@ describe('createModelAccessor', () => { }); it('does not expose ilike on non-textual fields', () => { - const post = createModelAccessor(context, 'Post'); + const post = createModelAccessor(context, 'public', 'Post'); const field = post['views'] as unknown as Record; expect(field['ilike']).toBeUndefined(); }); it('creates list literal, null check, and order directive helpers', () => { - const accessor = createModelAccessor(context, 'Post'); + const accessor = createModelAccessor(context, 'public', 'Post'); expect(accessor['id']!.in([1, 2, 3])).toEqual( BinaryExpr.in( @@ -142,7 +142,7 @@ describe('createModelAccessor', () => { expect(accessor['id']!.asc()).toEqual(OrderByItem.asc(ColumnRef.of('posts', 'id'))); expect(accessor['id']!.desc()).toEqual(OrderByItem.desc(ColumnRef.of('posts', 'id'))); - const user = createModelAccessor(context, 'User'); + const user = createModelAccessor(context, 'public', 'User'); expect(user['email']!.isNull()).toEqual(NullCheckExpr.isNull(ColumnRef.of('users', 'email'))); expect(user['email']!.isNotNull()).toEqual( NullCheckExpr.isNotNull(ColumnRef.of('users', 'email')), @@ -150,7 +150,7 @@ describe('createModelAccessor', () => { }); it('creates some() relation filters as EXISTS subqueries', () => { - const accessor = createModelAccessor(context, 'User'); + const accessor = createModelAccessor(context, 'public', 'User'); expect(accessor['posts']!.some()).toEqual( ExistsExpr.exists( @@ -162,7 +162,7 @@ describe('createModelAccessor', () => { }); it('creates none() and every() relation filters with NOT EXISTS semantics', () => { - const accessor = createModelAccessor(context, 'User'); + const accessor = createModelAccessor(context, 'public', 'User'); const noneExpr = accessor['posts']!.none({ views: 10 }) as ExistsExpr; expect(noneExpr.notExists).toBe(true); @@ -184,7 +184,7 @@ describe('createModelAccessor', () => { }); it('treats every({}) as vacuously true and none() as a plain anti-exists join', () => { - const accessor = createModelAccessor(context, 'User'); + const accessor = createModelAccessor(context, 'public', 'User'); expect(accessor['posts']!.every({})).toEqual(AndExpr.true()); @@ -196,7 +196,7 @@ describe('createModelAccessor', () => { }); it('supports nested relation filters', () => { - const accessor = createModelAccessor(context, 'User'); + const accessor = createModelAccessor(context, 'public', 'User'); const expr = accessor['posts']!.some((post) => post['comments']!.some((comment) => comment['body']!.like('%urgent%')), ) as ExistsExpr; @@ -207,7 +207,7 @@ describe('createModelAccessor', () => { }); it('keeps proxy symbol access undefined and relation shorthand maps null and undefined', () => { - const user = createModelAccessor(context, 'User'); + const user = createModelAccessor(context, 'public', 'User'); expect((user as Record)[Symbol.iterator]).toBeUndefined(); // Unknown fields in a shorthand predicate are surfaced loudly — silent skip would drop user intent (a typo'd filter would match every row). @@ -221,7 +221,7 @@ describe('createModelAccessor', () => { BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ColumnRef.of('users', 'id')), ); - const post = createModelAccessor(context, 'Post'); + const post = createModelAccessor(context, 'public', 'Post'); const nullExpr = post['comments']!.some({ body: null }) as ExistsExpr; expect(nullExpr.subquery.where).toEqual( AndExpr.of([ @@ -239,7 +239,7 @@ describe('createModelAccessor', () => { ...(models['User'] as Record), relations: { posts: { - to: { model: 'Post', namespace: '__unbound__' }, + to: { model: 'Post', namespace: 'public' }, cardinality: '1:N', on: { localFields: [], @@ -254,6 +254,7 @@ describe('createModelAccessor', () => { ( createModelAccessor( { ...context, contract: brokenJoinContract } as never, + 'public', 'User', ) as unknown as Record unknown }> )['posts']!.some(), @@ -278,7 +279,7 @@ describe('createModelAccessor', () => { relations: { ...user.relations, posts: { - to: { model: 'Post', namespace: '__unbound__' }, + to: { model: 'Post', namespace: 'public' }, cardinality: '1:N', on: { localFields: ['id', 'email'], @@ -293,6 +294,7 @@ describe('createModelAccessor', () => { const compositeExpr = ( createModelAccessor( { ...context, contract: compositeContract } as never, + 'public', 'User', ) as unknown as Record unknown }> )['posts']!.some() as ExistsExpr; @@ -322,7 +324,7 @@ describe('createModelAccessor', () => { relations: { ...user.relations, posts: { - to: { model: 'Post', namespace: '__unbound__' }, + to: { model: 'Post', namespace: 'public' }, cardinality: '1:N', on: { localFields: ['id', 'name'], @@ -337,6 +339,7 @@ describe('createModelAccessor', () => { const fallbackExpr = ( createModelAccessor( { ...context, contract: noTargetFieldsContract } as never, + 'public', 'User', ) as unknown as Record unknown }> )['posts']!.some() as ExistsExpr; @@ -364,6 +367,7 @@ describe('createModelAccessor', () => { // Contract claims the User model lives in `users_storage`, but storage.tables has no entry for it. The Proxy returns undefined for fields whose column cannot be resolved, matching plain JS object semantics. Downstream consumers (or TypeScript at compile time) are responsible for noticing the missing column. const accessor = createModelAccessor( { ...context, contract: storageFallbackContract } as never, + 'public', 'User', ); expect(accessor['name']).toBeUndefined(); @@ -381,14 +385,16 @@ describe('createModelAccessor', () => { })); expect( - createModelAccessor({ ...context, contract: modelNameFallbackContract } as never, 'User')[ - 'name' - ]!.isNull(), + createModelAccessor( + { ...context, contract: modelNameFallbackContract } as never, + 'public', + 'User', + )['name']!.isNull(), ).toEqual(NullCheckExpr.isNull(ColumnRef.of('users', 'name'))); }); it('combines relation shorthand fields with and() and rejects missing join arrays', () => { - const accessor = createModelAccessor(context, 'User'); + const accessor = createModelAccessor(context, 'public', 'User'); const predicate = accessor['posts']!.some({ title: 'A', views: 1 }) as ExistsExpr; expect(predicate.subquery.where).toEqual( @@ -408,7 +414,7 @@ describe('createModelAccessor', () => { ...(models['User'] as Record), relations: { posts: { - to: { model: 'Post', namespace: '__unbound__' }, + to: { model: 'Post', namespace: 'public' }, cardinality: '1:N', on: { localFields: [], targetFields: [] }, }, @@ -420,6 +426,7 @@ describe('createModelAccessor', () => { ( createModelAccessor( { ...context, contract: contractWithoutJoinArrays } as never, + 'public', 'User', ) as unknown as Record unknown }> )['posts']!.some(), @@ -429,7 +436,7 @@ describe('createModelAccessor', () => { describe('runtime trait-gating', () => { it('only creates equality methods when codec has equality trait', () => { const codecDescriptors = makeDescriptors({ 'pg/int4@1': ['equality'] }); - const accessor = createModelAccessor({ ...context, codecDescriptors }, 'Post'); + const accessor = createModelAccessor({ ...context, codecDescriptors }, 'public', 'Post'); const field = accessor['id'] as unknown as Record; expect(typeof field['eq']).toBe('function'); @@ -452,7 +459,7 @@ describe('createModelAccessor', () => { const codecDescriptors = makeDescriptors({ 'pg/text@1': ['equality', 'order', 'textual'], }); - const accessor = createModelAccessor({ ...context, codecDescriptors }, 'User'); + const accessor = createModelAccessor({ ...context, codecDescriptors }, 'public', 'User'); const field = accessor['name'] as unknown as Record; for (const method of [ @@ -476,7 +483,7 @@ describe('createModelAccessor', () => { it('throws when relation shorthand filter targets a field without equality trait', () => { const codecDescriptors = makeDescriptors({ 'pg/int4@1': ['order'] }); - const accessor = createModelAccessor({ ...context, codecDescriptors }, 'Post'); + const accessor = createModelAccessor({ ...context, codecDescriptors }, 'public', 'Post'); expect(() => accessor['comments']!.some({ postId: 42 })).toThrow( /does not support equality comparisons/, @@ -509,7 +516,12 @@ describe('createModelAccessor', () => { type FieldBag = Record; it('resolves an MTI variant column against the joined variant table', () => { - const feature = createModelAccessor(polyContext, 'Task', 'Feature') as unknown as FieldBag; + const feature = createModelAccessor( + polyContext, + 'public', + 'Task', + 'Feature', + ) as unknown as FieldBag; // `priority` lives on the joined `features` table, not the base `tasks`. expect(feature['priority']!.gte(3)).toEqual( new BinaryExpr( @@ -521,7 +533,12 @@ describe('createModelAccessor', () => { }); it('keeps base columns qualified against the base table when a variant is selected', () => { - const feature = createModelAccessor(polyContext, 'Task', 'Feature') as unknown as FieldBag; + const feature = createModelAccessor( + polyContext, + 'public', + 'Task', + 'Feature', + ) as unknown as FieldBag; expect(feature['title']!.eq('Dark mode')).toEqual( new BinaryExpr( 'eq', @@ -532,9 +549,14 @@ describe('createModelAccessor', () => { }); it('does not expose another variant column for the selected variant', () => { - const feature = createModelAccessor(polyContext, 'Task', 'Feature') as unknown as FieldBag; + const feature = createModelAccessor( + polyContext, + 'public', + 'Task', + 'Feature', + ) as unknown as FieldBag; expect(feature['priority']).toBeDefined(); - const bug = createModelAccessor(polyContext, 'Task', 'Bug') as unknown as FieldBag; + const bug = createModelAccessor(polyContext, 'public', 'Task', 'Bug') as unknown as FieldBag; // Bug is STI — its `severity` rides the base table, never the features join. expect(bug['severity']!.eq('critical')).toEqual( new BinaryExpr( @@ -548,7 +570,7 @@ describe('createModelAccessor', () => { }); it('leaves base resolution untouched when no variant is selected', () => { - const task = createModelAccessor(polyContext, 'Task') as unknown as FieldBag; + const task = createModelAccessor(polyContext, 'public', 'Task') as unknown as FieldBag; expect(task['title']!.eq('x')).toEqual( new BinaryExpr('eq', ColumnRef.of('tasks', 'title'), polyParam('tasks', 'title', 'x')), ); @@ -573,8 +595,8 @@ describe('createModelAccessor', () => { const codecDescriptors = makeDescriptors(traitsByCodec); const ctx = { ...context, queryOperations, codecDescriptors }; - const user = createModelAccessor(ctx, 'User'); - const post = createModelAccessor(ctx, 'Post'); + const user = createModelAccessor(ctx, 'public', 'User'); + const post = createModelAccessor(ctx, 'public', 'Post'); const name = user['name'] as unknown as Record; expect(typeof name['synthetic']).toBe('function'); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts index 659c3c6e24..fbed0e872c 100644 --- a/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts +++ b/packages/3-extensions/sql-orm-client/test/mutation-executor.test.ts @@ -63,20 +63,20 @@ describe('mutation-executor', () => { const contract = getTestContract(); expect( - hasNestedMutationCallbacks(contract, 'User', { + hasNestedMutationCallbacks(contract, 'public', 'User', { posts: (posts: { connect: (criterion: Record) => unknown }) => posts.connect({ id: 1 }), }), ).toBe(true); expect( - hasNestedMutationCallbacks(contract, 'User', { + hasNestedMutationCallbacks(contract, 'public', 'User', { posts: { kind: 'connect', criteria: [{ id: 1 }] }, }), ).toBe(false); expect( - hasNestedMutationCallbacks(contract, 'User', { + hasNestedMutationCallbacks(contract, 'public', 'User', { name: () => ({ kind: 'connect' }), }), ).toBe(false); @@ -124,14 +124,14 @@ describe('mutation-executor', () => { }); expect( - hasNestedMutationCallbacks(malformed, 'User', { + hasNestedMutationCallbacks(malformed, 'public', 'User', { posts: (posts: { connect: (criterion: Record) => unknown }) => posts.connect({ id: 1 }), }), ).toBe(true); expect( - hasNestedMutationCallbacks(contract, 'UnknownModel', { + hasNestedMutationCallbacks(contract, 'public', 'UnknownModel', { anything: () => ({ kind: 'connect' }), }), ).toBe(false); @@ -140,9 +140,9 @@ describe('mutation-executor', () => { it('buildPrimaryKeyFilterFromRow() resolves mapped keys and throws when missing', () => { const contract = getTestContract(); - expect(buildPrimaryKeyFilterFromRow(contract, 'User', { id: 7 })).toEqual({ id: 7 }); + expect(buildPrimaryKeyFilterFromRow(contract, 'public', 'User', { id: 7 })).toEqual({ id: 7 }); - expect(() => buildPrimaryKeyFilterFromRow(contract, 'User', {})).toThrow( + expect(() => buildPrimaryKeyFilterFromRow(contract, 'public', 'User', {})).toThrow( /Missing primary key field "id"/, ); }); @@ -176,7 +176,7 @@ describe('mutation-executor', () => { }, } as unknown as TestContract; - expect(buildPrimaryKeyFilterFromRow(withCustomPk, 'User', { pk_id: 99 })).toEqual({ + expect(buildPrimaryKeyFilterFromRow(withCustomPk, 'public', 'User', { pk_id: 99 })).toEqual({ pk_id: 99, }); }); @@ -190,6 +190,7 @@ describe('mutation-executor', () => { const created = await executeNestedCreateMutation({ context: { ...getTestContext(), contract }, runtime: transactional.runtime, + namespaceId: 'public', modelName: 'User', data: { id: 1, name: 'Alice', email: 'alice@example.com' } as never, }); @@ -215,6 +216,7 @@ describe('mutation-executor', () => { const created = await executeNestedCreateMutation({ context: { ...getTestContext(), contract }, runtime: runtimeWithBareTransaction, + namespaceId: 'public', modelName: 'User', data: { id: 1, name: 'Alice', email: 'alice@example.com' } as never, }); @@ -232,6 +234,7 @@ describe('mutation-executor', () => { executeNestedCreateMutation({ context: { ...getTestContext(), contract }, runtime: transactional.runtime, + namespaceId: 'public', modelName: 'User', data: { id: 1, name: 'Alice', email: 'alice@example.com' } as never, }), @@ -254,6 +257,7 @@ describe('mutation-executor', () => { await executeNestedCreateMutation({ context: { ...getTestContext(), contract }, runtime: scopedRuntime, + namespaceId: 'public', modelName: 'User', data: { id: 1, name: 'Alice', email: 'alice@example.com' } as never, }); @@ -269,6 +273,7 @@ describe('mutation-executor', () => { executeNestedCreateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'User', data: { id: 1, @@ -283,6 +288,7 @@ describe('mutation-executor', () => { executeNestedCreateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'User', data: { id: 1, @@ -302,6 +308,7 @@ describe('mutation-executor', () => { executeNestedCreateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'Post', data: { id: 1, @@ -317,6 +324,7 @@ describe('mutation-executor', () => { executeNestedCreateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'User', data: { id: 1, @@ -336,6 +344,7 @@ describe('mutation-executor', () => { executeNestedCreateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'Post', data: { id: 1, @@ -352,6 +361,7 @@ describe('mutation-executor', () => { executeNestedCreateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'Post', data: { id: 1, @@ -368,6 +378,7 @@ describe('mutation-executor', () => { executeNestedCreateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'Post', data: { id: 1, @@ -383,6 +394,7 @@ describe('mutation-executor', () => { executeNestedCreateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'Post', data: { id: 1, @@ -419,6 +431,7 @@ describe('mutation-executor', () => { executeNestedCreateMutation({ context: { ...getTestContext(), contract: withManyToMany }, runtime, + namespaceId: 'public', modelName: 'User', data: { id: 1, @@ -442,6 +455,7 @@ describe('mutation-executor', () => { const created = await executeNestedCreateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'Post', data: { id: 1, @@ -491,6 +505,7 @@ describe('mutation-executor', () => { const created = await executeNestedCreateMutation({ context: { ...getTestContext(), contract: sparseAuthorRelation }, runtime, + namespaceId: 'public', modelName: 'Post', data: { id: 1, @@ -512,6 +527,7 @@ describe('mutation-executor', () => { const updated = await executeNestedUpdateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'User', filters: [userIdFilter], data: { name: 'Alice Updated' } as never, @@ -531,6 +547,7 @@ describe('mutation-executor', () => { const updated = await executeNestedUpdateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'Post', filters: [postIdFilter], data: { @@ -549,6 +566,7 @@ describe('mutation-executor', () => { const updated = await executeNestedUpdateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'User', filters: [userIdFilter], data: { name: 'Updated' } as never, @@ -566,6 +584,7 @@ describe('mutation-executor', () => { executeNestedUpdateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'User', filters: [userIdFilter], data: { @@ -579,6 +598,7 @@ describe('mutation-executor', () => { const connected = await executeNestedUpdateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'User', filters: [userIdFilter], data: { @@ -593,6 +613,7 @@ describe('mutation-executor', () => { const disconnected = await executeNestedUpdateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'User', filters: [userIdFilter], data: { @@ -607,6 +628,7 @@ describe('mutation-executor', () => { executeNestedUpdateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'User', filters: [userIdFilter], data: { @@ -645,6 +667,7 @@ describe('mutation-executor', () => { const updated = await executeNestedUpdateMutation({ context: { ...getTestContext(), contract: compositeRelationContract }, runtime, + namespaceId: 'public', modelName: 'User', filters: [userIdFilter], data: { @@ -664,6 +687,7 @@ describe('mutation-executor', () => { executeNestedUpdateMutation({ context: { ...getTestContext(), contract }, runtime, + namespaceId: 'public', modelName: 'User', filters: [userIdFilter], data: { @@ -683,6 +707,7 @@ describe('mutation-executor', () => { const created = await executeNestedCreateMutation({ context: getTestContext(), runtime, + namespaceId: 'public', modelName: 'User', data: { id: 1, name: 'Alice', email: 'alice@test.com' } as never, }); @@ -701,6 +726,7 @@ describe('mutation-executor', () => { const created = await executeNestedCreateMutation({ context: getTestContext(), runtime, + namespaceId: 'public', modelName: 'User', data: { id: 1, name: 'Alice', email: 'alice@test.com' } as never, }); diff --git a/packages/3-extensions/sql-orm-client/test/mutation-include-read-back-namespace.test.ts b/packages/3-extensions/sql-orm-client/test/mutation-include-read-back-namespace.test.ts new file mode 100644 index 0000000000..5ba9ed1b62 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/mutation-include-read-back-namespace.test.ts @@ -0,0 +1,105 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { + BinaryExpr, + ListExpression, + ParamRef, + SelectAst, +} from '@prisma-next/sql-relational-core/ast'; +import { blindCast } from '@prisma-next/utils/casts'; +import { describe, expect, it } from 'vitest'; +import { reloadMutationRowsByIdentities } from '../src/collection-dispatch'; +import { createMockRuntime, type MockRuntime } from './helpers'; + +function storageTable(columnCodecs: Record) { + const cols: Record = {}; + for (const [column, codecId] of Object.entries(columnCodecs)) { + cols[column] = { codecId, nativeType: codecId, nullable: false }; + } + return { + columns: cols, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; +} + +function model() { + return { + fields: {}, + relations: {}, + storage: { table: 'users', fields: { id: { column: 'id' } } }, + }; +} + +// `User` lives in BOTH namespaces, each backed by a table with the SAME bare +// name `users`, but the PK column `id` carries a DIFFERENT codec per namespace +// (`pg/int4@1` in `public`, `pg/text@1` in `auth`). The mutation-with-include +// read-back keys its `id IN (...)` filter on this PK column, so the param it +// stamps must discriminate by the namespace coordinate — first-matching the +// wrong namespace would stamp the wrong codec (or, since the bare name is +// ambiguous, throw outright). +const twoNamespaceContract = blindCast, 'hand-built multi-namespace fixture'>({ + target: 'postgres', + targetFamily: 'sql', + capabilities: { returning: { enabled: true } }, + domain: { + namespaces: { + public: { models: { User: model() } }, + auth: { models: { User: model() } }, + }, + }, + storage: { + storageHash: 'stub', + namespaces: { + public: { + id: 'public', + entries: { table: { users: storageTable({ id: 'pg/int4@1' }) } }, + }, + auth: { + id: 'auth', + entries: { table: { users: storageTable({ id: 'pg/text@1' }) } }, + }, + }, + }, +}); + +async function readBackIdentityCodec( + runtime: MockRuntime, + namespaceId: string, + identityValue: unknown, +): Promise<{ codecId: string } | undefined> { + runtime.setNextResults([[]]); + await reloadMutationRowsByIdentities>({ + contract: twoNamespaceContract, + runtime, + tableName: 'users', + modelName: 'User', + namespaceId, + identityRows: [{ id: identityValue }], + selectedFields: ['id'], + includes: [], + }).toArray(); + + const plan = runtime.executions[runtime.executions.length - 1]?.plan; + const ast = (plan as { ast: SelectAst }).ast; + const where = ast.where as BinaryExpr; + const list = where.right as ListExpression; + const param = list.values[0] as ParamRef; + return param.codec; +} + +describe('mutation-with-include read-back namespace coordinate', () => { + it('stamps the public-namespace PK codec on the identity-filter param', async () => { + const runtime = createMockRuntime(); + const codec = await readBackIdentityCodec(runtime, 'public', 1); + expect(codec).toEqual({ codecId: 'pg/int4@1' }); + }); + + it('stamps the auth-namespace PK codec on the identity-filter param', async () => { + const runtime = createMockRuntime(); + const codec = await readBackIdentityCodec(runtime, 'auth', 'u1'); + expect(codec).toEqual({ codecId: 'pg/text@1' }); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/namespace-qualification.test.ts b/packages/3-extensions/sql-orm-client/test/namespace-qualification.test.ts index 43ad7f1583..cd96195516 100644 --- a/packages/3-extensions/sql-orm-client/test/namespace-qualification.test.ts +++ b/packages/3-extensions/sql-orm-client/test/namespace-qualification.test.ts @@ -1,9 +1,5 @@ import { createPostgresAdapter } from '@prisma-next/adapter-postgres/adapter'; -import { - type Contract, - type ContractModelBase, - DomainNamespaceResolutionError, -} from '@prisma-next/contract/types'; +import type { Contract, ContractModelBase } from '@prisma-next/contract/types'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import type { SqlStorage as SqlStorageType } from '@prisma-next/sql-contract/types'; import { SqlStorage, type SqlStorageInput, StorageTable } from '@prisma-next/sql-contract/types'; @@ -63,7 +59,7 @@ const publicPostgresContract = { } as unknown as PostgresContract; describe('ORM namespace qualification', () => { - it('throws on a multi-domain-namespace contract rather than silently picking one', () => { + it('throws on an absent namespace rather than silently picking a present one', () => { const contract = { ...publicPostgresContract, domain: { @@ -76,30 +72,37 @@ describe('ORM namespace qualification', () => { }, } as Contract; - expect(() => compileSelect(contract, 'users', emptyState(), 'User')).toThrow( - DomainNamespaceResolutionError, + expect(() => compileSelect(contract, 'missing', 'users', emptyState(), 'User')).toThrow( + /namespace "missing" is not present/, ); }); it('stamps public on TableSource for select, insert, and delete plans', () => { - const selectPlan = compileSelect(publicPostgresContract, 'users', emptyState(), 'User'); + const selectPlan = compileSelect( + publicPostgresContract, + 'public', + 'users', + emptyState(), + 'User', + ); expect((selectPlan.ast as { from: TableSource }).from.namespaceId).toBe('public'); const insertPlan = compileInsertReturning( publicPostgresContract, + 'public', 'users', [{ id: 1, email: 'a@example.com' }], ['id', 'email'], ); expect((insertPlan.ast as { table: TableSource }).table.namespaceId).toBe('public'); - const deletePlan = compileDeleteCount(publicPostgresContract, 'users', []); + const deletePlan = compileDeleteCount(publicPostgresContract, 'public', 'users', []); expect((deletePlan.ast as { table: TableSource }).table.namespaceId).toBe('public'); }); it('renders schema-qualified SQL for Postgres via the adapter lower path', () => { const adapter = createPostgresAdapter(); - const selectPlan = compileSelect(publicPostgresContract, 'users', { + const selectPlan = compileSelect(publicPostgresContract, 'public', 'users', { ...emptyState(), selectedFields: ['id', 'email'], }); @@ -111,6 +114,7 @@ describe('ORM namespace qualification', () => { const insertPlan = compileInsertReturning( publicPostgresContract, + 'public', 'users', [{ id: 1, email: 'a@example.com' }], ['id', 'email'], @@ -158,7 +162,7 @@ describe('ORM namespace qualification', () => { }), } as unknown as Contract; - const selectPlan = compileSelect(sqliteContract, 'users', { + const selectPlan = compileSelect(sqliteContract, UNBOUND_NAMESPACE_ID, 'users', { ...emptyState(), selectedFields: ['id'], }); diff --git a/packages/3-extensions/sql-orm-client/test/orm-namespace-crud.test.ts b/packages/3-extensions/sql-orm-client/test/orm-namespace-crud.test.ts new file mode 100644 index 0000000000..fdcd2054a0 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/orm-namespace-crud.test.ts @@ -0,0 +1,139 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { TableSource } from '@prisma-next/sql-relational-core/ast'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { blindCast } from '@prisma-next/utils/casts'; +import { describe, expect, it } from 'vitest'; +import { orm } from '../src/orm'; +import { createMockRuntime, type MockRuntime } from './helpers'; + +function model(table: string, fieldColumns: Record) { + const fields: Record = {}; + const storageFields: Record = {}; + for (const [field, column] of Object.entries(fieldColumns)) { + fields[field] = { type: { kind: 'scalar', codecId: 'pg/text@1' } }; + storageFields[field] = { column }; + } + return { fields, relations: {}, storage: { table, fields: storageFields } }; +} + +function storageTable(columns: string[]) { + const cols: Record = {}; + for (const column of columns) { + cols[column] = { codecId: 'pg/text@1', nativeType: 'text', nullable: false }; + } + return { + columns: cols, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; +} + +// Same bare model name (`User`) in two namespaces, distinct field maps and +// distinct tables, so CRUD execution must resolve metadata within the +// collection's namespace rather than the default/first-match. +const twoNamespaceContract = blindCast, 'hand-built multi-namespace fixture'>({ + target: 'postgres', + targetFamily: 'sql', + capabilities: {}, + domain: { + namespaces: { + public: { models: { User: model('users', { id: 'id', email: 'email_addr' }) } }, + auth: { models: { User: model('auth_users', { id: 'id', token: 'token_col' }) } }, + }, + }, + storage: { + storageHash: 'stub', + namespaces: { + public: { id: 'public', entries: { table: { users: storageTable(['id', 'email_addr']) } } }, + auth: { id: 'auth', entries: { table: { auth_users: storageTable(['id', 'token_col']) } } }, + }, + }, +}); + +type CrudCollection = { + all(): { toArray(): Promise[]> }; + select(...fields: string[]): CrudCollection; + where(filter: Record): CrudCollection; + createCount(rows: readonly Record[]): Promise; + updateCount(values: Record): Promise; + deleteCount(): Promise; +}; +type TwoNamespaceOrm = { public: { User: CrudCollection }; auth: { User: CrudCollection } }; + +function setup(): { db: TwoNamespaceOrm; runtime: MockRuntime } { + const runtime = createMockRuntime(); + const db = blindCast( + orm({ + runtime, + context: blindCast>, 'stub execution context'>({ + contract: twoNamespaceContract, + applyMutationDefaults: () => [], + codecDescriptors: { descriptorFor: () => ({ traits: ['equality'] }) }, + }), + }), + ); + return { db, runtime }; +} + +function lastPlanTable(runtime: MockRuntime): TableSource { + const plan = runtime.executions[runtime.executions.length - 1]?.plan; + const ast = blindCast< + { from?: TableSource; table?: TableSource }, + 'plan ast carries a table source' + >((plan as { ast: unknown }).ast); + const source = ast.from ?? ast.table; + if (!source) throw new Error('plan ast had neither from nor table'); + return source; +} + +describe('namespaced orm CRUD execution', () => { + it('selects per-namespace, mapping returned rows with the namespace column map', async () => { + const { db, runtime } = setup(); + + runtime.setNextResults([[{ id: 1, email_addr: 'a@example.com' }]]); + const publicRows = await db.public.User.all().toArray(); + expect(publicRows).toEqual([{ id: 1, email: 'a@example.com' }]); + expect(lastPlanTable(runtime).name).toBe('users'); + expect(lastPlanTable(runtime).namespaceId).toBe('public'); + + runtime.setNextResults([[{ id: 2, token_col: 'tok' }]]); + const authRows = await db.auth.User.all().toArray(); + expect(authRows).toEqual([{ id: 2, token: 'tok' }]); + expect(lastPlanTable(runtime).name).toBe('auth_users'); + expect(lastPlanTable(runtime).namespaceId).toBe('auth'); + }); + + it('binds a shorthand where within the namespace', async () => { + const { db, runtime } = setup(); + runtime.setNextResults([[]]); + await db.auth.User.where({ token: 'tok' }).all().toArray(); + expect(lastPlanTable(runtime).name).toBe('auth_users'); + }); + + it('inserts within the namespace target table', async () => { + const { db, runtime } = setup(); + expect(await db.public.User.createCount([{ email: 'a@example.com' }])).toBe(1); + expect(lastPlanTable(runtime).namespaceId).toBe('public'); + expect(lastPlanTable(runtime).name).toBe('users'); + + expect(await db.auth.User.createCount([{ token: 'tok' }])).toBe(1); + expect(lastPlanTable(runtime).name).toBe('auth_users'); + }); + + it('updates within the namespace target table', async () => { + const { db, runtime } = setup(); + runtime.setNextResults([[{ id: 1 }], []]); + await db.auth.User.where({ token: 'tok' }).updateCount({ token: 'new' }); + expect(lastPlanTable(runtime).name).toBe('auth_users'); + }); + + it('deletes within the namespace target table', async () => { + const { db, runtime } = setup(); + runtime.setNextResults([[]]); + await db.public.User.where({ email: 'a@example.com' }).deleteCount(); + expect(lastPlanTable(runtime).name).toBe('users'); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/orm-namespace-relation.test.ts b/packages/3-extensions/sql-orm-client/test/orm-namespace-relation.test.ts new file mode 100644 index 0000000000..2287c293f8 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/orm-namespace-relation.test.ts @@ -0,0 +1,150 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { TableSource } from '@prisma-next/sql-relational-core/ast'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { blindCast } from '@prisma-next/utils/casts'; +import { describe, expect, it } from 'vitest'; +import { orm } from '../src/orm'; +import { createMockRuntime, type MockRuntime } from './helpers'; + +function storageTable(columns: string[]) { + const cols: Record = {}; + for (const column of columns) { + cols[column] = { codecId: 'pg/text@1', nativeType: 'text', nullable: false }; + } + return { + columns: cols, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; +} + +// `User` is declared in BOTH namespaces (same bare model name), so resolving the +// `public.Profile.user -> auth.User` relation target without its namespace would +// first-match `public.User`. The base model `Profile` declares the cross-namespace +// relation, exercising relation-target resolution on a multi-namespace contract. +const profileModel = { + fields: { + id: { type: { kind: 'scalar', codecId: 'pg/text@1' } }, + bio: { type: { kind: 'scalar', codecId: 'pg/text@1' } }, + userId: { type: { kind: 'scalar', codecId: 'pg/text@1' } }, + }, + relations: { + user: { + to: { model: 'User', namespace: 'auth' }, + cardinality: 'N:1', + on: { localFields: ['userId'], targetFields: ['id'] }, + }, + }, + storage: { + table: 'profiles', + fields: { id: { column: 'id' }, bio: { column: 'bio_col' }, userId: { column: 'user_id' } }, + }, +}; + +const publicUserModel = { + fields: { id: { type: { kind: 'scalar', codecId: 'pg/text@1' } } }, + relations: {}, + storage: { table: 'users', fields: { id: { column: 'id' }, email: { column: 'email_addr' } } }, +}; + +const authUserModel = { + fields: { id: { type: { kind: 'scalar', codecId: 'pg/text@1' } } }, + relations: {}, + storage: { + table: 'auth_users', + fields: { id: { column: 'id' }, token: { column: 'token_col' } }, + }, +}; + +const twoNamespaceContract = blindCast, 'hand-built multi-namespace fixture'>({ + target: 'postgres', + targetFamily: 'sql', + capabilities: { + returning: { enabled: true }, + sql: { jsonAgg: true, returning: true, lateral: true }, + postgres: { jsonAgg: true, returning: true, lateral: true }, + }, + domain: { + namespaces: { + public: { models: { Profile: profileModel, User: publicUserModel } }, + auth: { models: { User: authUserModel } }, + }, + }, + storage: { + storageHash: 'stub', + namespaces: { + public: { + id: 'public', + entries: { + table: { + profiles: storageTable(['id', 'bio_col', 'user_id']), + users: storageTable(['id', 'email_addr']), + }, + }, + }, + auth: { id: 'auth', entries: { table: { auth_users: storageTable(['id', 'token_col']) } } }, + }, + }, +}); + +type RelationCollection = { + create(values: Record): Promise>; + include(relationName: string): { first(): Promise | null> }; +}; +type TwoNamespaceOrm = { public: { Profile: RelationCollection } }; + +function setup(): { db: TwoNamespaceOrm; runtime: MockRuntime } { + const runtime = createMockRuntime(); + const db = blindCast( + orm({ + runtime, + context: blindCast>, 'stub execution context'>({ + contract: twoNamespaceContract, + applyMutationDefaults: () => [], + codecDescriptors: { descriptorFor: () => ({ traits: ['equality'] }) }, + }), + }), + ); + return { db, runtime }; +} + +function lastPlanTable(runtime: MockRuntime): TableSource { + const plan = runtime.executions[runtime.executions.length - 1]?.plan; + const ast = blindCast< + { from?: TableSource; table?: TableSource }, + 'plan ast carries a table source' + >((plan as { ast: unknown }).ast); + const source = ast.from ?? ast.table; + if (!source) throw new Error('plan ast had neither from nor table'); + return source; +} + +describe('namespaced orm cross-namespace relation', () => { + it('executes base CRUD on a model that declares a cross-namespace relation', async () => { + const { db, runtime } = setup(); + + runtime.setNextResults([[{ id: 1, bio_col: 'hi', user_id: 9 }]]); + const created = await db.public.Profile.create({ bio: 'hi', userId: 9 }); + expect(created).toEqual({ id: 1, bio: 'hi', userId: 9 }); + expect(lastPlanTable(runtime).name).toBe('profiles'); + expect(lastPlanTable(runtime).namespaceId).toBe('public'); + }); + + it('resolves a cross-namespace include within the target namespace', async () => { + const { db, runtime } = setup(); + + runtime.setNextResults([ + [{ id: 1, bio_col: 'hi', user_id: 9, user: '[{"id":9,"token_col":"tok"}]' }], + ]); + const profile = await db.public.Profile.include('user').first(); + expect(profile).toEqual({ + id: 1, + bio: 'hi', + userId: 9, + user: { id: 9, token: 'tok' }, + }); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/orm-namespace-resolution.test.ts b/packages/3-extensions/sql-orm-client/test/orm-namespace-resolution.test.ts new file mode 100644 index 0000000000..0005a07bf6 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/orm-namespace-resolution.test.ts @@ -0,0 +1,107 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { blindCast } from '@prisma-next/utils/casts'; +import { describe, expect, it } from 'vitest'; +import { Collection } from '../src/collection'; +import { + getColumnToFieldMap, + getFieldToColumnMap, + resolveModelTableName, +} from '../src/collection-contract'; +import { createMockRuntime } from './helpers'; + +function model(table: string, fieldColumns: Record) { + const fields: Record = {}; + const storageFields: Record = {}; + for (const [field, column] of Object.entries(fieldColumns)) { + fields[field] = { type: { kind: 'scalar', codecId: 'pg/text@1' } }; + storageFields[field] = { column }; + } + return { fields, relations: {}, storage: { table, fields: storageFields } }; +} + +function storageTable(columns: string[]) { + const cols: Record = {}; + for (const column of columns) { + cols[column] = { codecId: 'pg/text@1', nativeType: 'text', nullable: false }; + } + return { + columns: cols, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; +} + +// Same bare model name (`User`) in two namespaces, with distinct field→column +// maps and distinct backing tables, so metadata resolution must discriminate +// by namespace coordinate rather than fall back to the default/first-match. +const twoNamespaceContract = blindCast, 'hand-built multi-namespace fixture'>({ + target: 'postgres', + targetFamily: 'sql', + capabilities: {}, + domain: { + namespaces: { + public: { models: { User: model('users', { id: 'id', email: 'email_addr' }) } }, + auth: { models: { User: model('auth_users', { id: 'id', token: 'token_col' }) } }, + }, + }, + storage: { + storageHash: 'stub', + namespaces: { + public: { id: 'public', entries: { table: { users: storageTable(['id', 'email_addr']) } } }, + auth: { id: 'auth', entries: { table: { auth_users: storageTable(['id', 'token_col']) } } }, + }, + }, +}); + +describe('namespace-scoped metadata resolution', () => { + it('resolves field→column maps within the named namespace, discriminating per namespace', () => { + expect(getFieldToColumnMap(twoNamespaceContract, 'public', 'User')).toEqual({ + id: 'id', + email: 'email_addr', + }); + expect(getFieldToColumnMap(twoNamespaceContract, 'auth', 'User')).toEqual({ + id: 'id', + token: 'token_col', + }); + }); + + it('resolves column→field maps within the named namespace', () => { + expect(getColumnToFieldMap(twoNamespaceContract, 'public', 'User')).toEqual({ + id: 'id', + email_addr: 'email', + }); + expect(getColumnToFieldMap(twoNamespaceContract, 'auth', 'User')).toEqual({ + id: 'id', + token_col: 'token', + }); + }); + + it('resolves the storage table within the named namespace', () => { + expect(resolveModelTableName(twoNamespaceContract, 'public', 'User')).toBe('users'); + expect(resolveModelTableName(twoNamespaceContract, 'auth', 'User')).toBe('auth_users'); + }); + + it('throws when the named namespace is not present on the contract', () => { + expect(() => getFieldToColumnMap(twoNamespaceContract, 'missing', 'User')).toThrow( + /namespace "missing" is not present/, + ); + }); + + it('a collection constructed with a namespace resolves its own table within that namespace', () => { + const ctx = { + runtime: createMockRuntime(), + context: blindCast< + ExecutionContext>, + 'stub execution context for metadata resolution' + >({ contract: twoNamespaceContract }), + }; + const publicUsers = new Collection(ctx, 'User', { namespaceId: 'public' }); + const authUsers = new Collection(ctx, 'User', { namespaceId: 'auth' }); + expect(publicUsers.tableName).toBe('users'); + expect(authUsers.tableName).toBe('auth_users'); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/orm-namespace-returning-crud.test.ts b/packages/3-extensions/sql-orm-client/test/orm-namespace-returning-crud.test.ts new file mode 100644 index 0000000000..2f6d32e3a5 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/orm-namespace-returning-crud.test.ts @@ -0,0 +1,124 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { TableSource } from '@prisma-next/sql-relational-core/ast'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { blindCast } from '@prisma-next/utils/casts'; +import { describe, expect, it } from 'vitest'; +import { orm } from '../src/orm'; +import { createMockRuntime, type MockRuntime } from './helpers'; + +function model(table: string, fieldColumns: Record) { + const fields: Record = {}; + const storageFields: Record = {}; + for (const [field, column] of Object.entries(fieldColumns)) { + fields[field] = { type: { kind: 'scalar', codecId: 'pg/text@1' } }; + storageFields[field] = { column }; + } + return { fields, relations: {}, storage: { table, fields: storageFields } }; +} + +function storageTable(columns: string[]) { + const cols: Record = {}; + for (const column of columns) { + cols[column] = { codecId: 'pg/text@1', nativeType: 'text', nullable: false }; + } + return { + columns: cols, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; +} + +// Same bare model name (`User`) in two namespaces, no relations, distinct field +// maps and tables, so returning-row mutation execution must resolve metadata +// within the collection's namespace rather than the default/first-match. +const twoNamespaceContract = blindCast, 'hand-built multi-namespace fixture'>({ + target: 'postgres', + targetFamily: 'sql', + capabilities: { returning: { enabled: true } }, + domain: { + namespaces: { + public: { models: { User: model('users', { id: 'id', email: 'email_addr' }) } }, + auth: { models: { User: model('auth_users', { id: 'id', token: 'token_col' }) } }, + }, + }, + storage: { + storageHash: 'stub', + namespaces: { + public: { id: 'public', entries: { table: { users: storageTable(['id', 'email_addr']) } } }, + auth: { id: 'auth', entries: { table: { auth_users: storageTable(['id', 'token_col']) } } }, + }, + }, +}); + +type ReturningCollection = { + create(values: Record): Promise>; + where(filter: Record): ReturningCollection; + deleteAll(): { toArray(): Promise[]> }; +}; +type TwoNamespaceOrm = { + public: { User: ReturningCollection }; + auth: { User: ReturningCollection }; +}; + +function setup(): { db: TwoNamespaceOrm; runtime: MockRuntime } { + const runtime = createMockRuntime(); + const db = blindCast( + orm({ + runtime, + context: blindCast>, 'stub execution context'>({ + contract: twoNamespaceContract, + applyMutationDefaults: () => [], + codecDescriptors: { descriptorFor: () => ({ traits: ['equality'] }) }, + }), + }), + ); + return { db, runtime }; +} + +function lastPlanTable(runtime: MockRuntime): TableSource { + const plan = runtime.executions[runtime.executions.length - 1]?.plan; + const ast = blindCast< + { from?: TableSource; table?: TableSource }, + 'plan ast carries a table source' + >((plan as { ast: unknown }).ast); + const source = ast.from ?? ast.table; + if (!source) throw new Error('plan ast had neither from nor table'); + return source; +} + +describe('namespaced orm returning-row mutation execution', () => { + it('create returns the inserted row mapped within the namespace', async () => { + const { db, runtime } = setup(); + + runtime.setNextResults([[{ id: 1, email_addr: 'a@example.com' }]]); + const publicRow = await db.public.User.create({ email: 'a@example.com' }); + expect(publicRow).toEqual({ id: 1, email: 'a@example.com' }); + expect(lastPlanTable(runtime).name).toBe('users'); + expect(lastPlanTable(runtime).namespaceId).toBe('public'); + + runtime.setNextResults([[{ id: 2, token_col: 'tok' }]]); + const authRow = await db.auth.User.create({ token: 'tok' }); + expect(authRow).toEqual({ id: 2, token: 'tok' }); + expect(lastPlanTable(runtime).name).toBe('auth_users'); + expect(lastPlanTable(runtime).namespaceId).toBe('auth'); + }); + + it('deleteAll returns the deleted rows mapped within the namespace', async () => { + const { db, runtime } = setup(); + + runtime.setNextResults([[{ id: 1, email_addr: 'a@example.com' }]]); + const publicDeleted = await db.public.User.where({ email: 'a@example.com' }) + .deleteAll() + .toArray(); + expect(publicDeleted).toEqual([{ id: 1, email: 'a@example.com' }]); + expect(lastPlanTable(runtime).name).toBe('users'); + + runtime.setNextResults([[{ id: 2, token_col: 'tok' }]]); + const authDeleted = await db.auth.User.where({ token: 'tok' }).deleteAll().toArray(); + expect(authDeleted).toEqual([{ id: 2, token: 'tok' }]); + expect(lastPlanTable(runtime).name).toBe('auth_users'); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/orm-namespaced.test.ts b/packages/3-extensions/sql-orm-client/test/orm-namespaced.test.ts new file mode 100644 index 0000000000..91d99bbed4 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/orm-namespaced.test.ts @@ -0,0 +1,90 @@ +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { describe, expect, it } from 'vitest'; +import { orm } from '../src/orm'; +import { createMockRuntime, type TestContract } from './helpers'; + +function model(table: string) { + return { + fields: { id: { nullable: false, type: { kind: 'scalar', codecId: 'pg/int4@1' } } }, + relations: {}, + storage: { table, fields: { id: { column: 'id' } } }, + }; +} + +function storageTable() { + return { + columns: { id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false } }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; +} + +// Two namespaces that declare the same bare model name (`User`) backed by +// different tables, plus a model unique to each namespace, so resolution must +// discriminate by namespace coordinate rather than fall back to the flat scan. +const twoNamespaceContract = { + target: 'postgres', + targetFamily: 'sql', + capabilities: {}, + domain: { + namespaces: { + public: { models: { User: model('users'), Post: model('posts') } }, + auth: { models: { User: model('auth_users'), Session: model('sessions') } }, + }, + }, + storage: { + storageHash: 'stub', + namespaces: { + public: { + id: 'public', + entries: { table: { users: storageTable(), posts: storageTable() } }, + }, + auth: { + id: 'auth', + entries: { table: { auth_users: storageTable(), sessions: storageTable() } }, + }, + }, + }, +}; + +type Accessor = { readonly modelName: string; readonly tableName: string }; +type TwoNamespaceOrm = { + public: { User: Accessor; Post: Accessor; Session: undefined }; + auth: { User: Accessor; Session: Accessor }; + User: Accessor; +}; + +function db() { + return orm({ + runtime: createMockRuntime(), + context: { + contract: twoNamespaceContract, + } as unknown as ExecutionContext, + }) as unknown as TwoNamespaceOrm; +} + +describe('namespaced orm accessor', () => { + it('resolves the same bare model name to the distinct table in each namespace', () => { + expect(db().public.User.tableName).toBe('users'); + expect(db().public.User.modelName).toBe('User'); + expect(db().auth.User.tableName).toBe('auth_users'); + expect(db().auth.User.modelName).toBe('User'); + }); + + it('scopes model lookup to the named namespace rather than the flat model set', () => { + // `Session` exists only in `auth`; the flat set contains it, so an + // unscoped facet would wrongly resolve `auth.Session` under `public`. + expect(() => db().public.Session).toThrow(); + expect(db().auth.Session.tableName).toBe('sessions'); + }); + + it('throws on flat bare-name access against a multi-namespace contract (bare=default deferred)', () => { + // With a required, leading namespace the flat surface resolves the sole + // domain namespace; a multi-namespace contract has no sole namespace, so + // bare access throws until bare=default lands. Namespace-qualified access + // (db().public.User) remains the supported path. + expect(() => db().User).toThrow(/exactly one domain namespace/); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/orm-namespaced.types.test-d.ts b/packages/3-extensions/sql-orm-client/test/orm-namespaced.types.test-d.ts new file mode 100644 index 0000000000..7ae7a28740 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/orm-namespaced.types.test-d.ts @@ -0,0 +1,56 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { expectTypeOf, test } from 'vitest'; +import { orm } from '../src/orm'; +import { createMockRuntime, getTestContext, type TestContract } from './helpers'; + +const db = orm({ runtime: createMockRuntime(), context: getTestContext() }); + +test('the namespace facet exposes its models as the same collection as the flat surface', () => { + expectTypeOf(db.public.User).toEqualTypeOf(db.User); + expectTypeOf(db.public.Post).toEqualTypeOf(db.Post); +}); + +test('the flat surface is retained alongside the namespace facet', () => { + expectTypeOf(db.User).toEqualTypeOf(db.public.User); +}); + +test('an undeclared namespace id is not a key on the typed surface', () => { + // @ts-expect-error 'auth' is not a declared domain namespace of this contract + db.auth; +}); + +type DomainNamespaceIds> = keyof C['domain']['namespaces']; +type StorageNamespaceIds> = keyof C['storage']['namespaces']; +// Top-level enums (no domain model) land in the storage-only `__unbound__` +// schema, so domain namespace ids are the storage namespace ids minus it. +type ModelBearingStorageNamespaceIds> = Exclude< + StorageNamespaceIds, + '__unbound__' +>; + +// A two-namespace shape reusing the generated fixture's namespace content, so +// the alignment assertion below exercises more than a single namespace id. +interface TwoNamespaceContract extends Omit { + readonly domain: Omit & { + readonly namespaces: { + readonly public: TestContract['domain']['namespaces']['public']; + readonly auth: TestContract['domain']['namespaces']['public']; + }; + }; + readonly storage: Omit & { + readonly namespaces: { + readonly public: TestContract['storage']['namespaces']['public']; + readonly auth: TestContract['storage']['namespaces']['public']; + }; + }; +} + +test('the namespaced orm keys equal the model-bearing namespaced sql keys', () => { + expectTypeOf>().toEqualTypeOf< + ModelBearingStorageNamespaceIds + >(); + expectTypeOf>().toEqualTypeOf< + ModelBearingStorageNamespaceIds + >(); +}); diff --git a/packages/3-extensions/sql-orm-client/test/orm-same-bare-table-name.test.ts b/packages/3-extensions/sql-orm-client/test/orm-same-bare-table-name.test.ts new file mode 100644 index 0000000000..a697458c35 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/orm-same-bare-table-name.test.ts @@ -0,0 +1,344 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { + ColumnRef, + ProjectionItem, + SelectAst, + TableSource, +} from '@prisma-next/sql-relational-core/ast'; +import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; +import { blindCast } from '@prisma-next/utils/casts'; +import { describe, expect, it } from 'vitest'; +import { orm } from '../src/orm'; +import { createMockRuntime, type MockRuntime } from './helpers'; + +function model(table: string, fieldColumns: Record) { + const fields: Record = {}; + const storageFields: Record = {}; + for (const [field, column] of Object.entries(fieldColumns)) { + fields[field] = { type: { kind: 'scalar', codecId: 'pg/text@1' } }; + storageFields[field] = { column }; + } + return { fields, relations: {}, storage: { table, fields: storageFields } }; +} + +function storageTable(columnCodecs: Record) { + const cols: Record = {}; + for (const [column, codecId] of Object.entries(columnCodecs)) { + cols[column] = { codecId, nativeType: codecId, nullable: false }; + } + return { + columns: cols, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; +} + +// `User` is declared in both namespaces, both backed by a table with the SAME +// bare name `users` but DIFFERING columns/codecs, so column/codec resolution +// must discriminate by the namespace coordinate. +const twoNamespaceContract = blindCast, 'hand-built multi-namespace fixture'>({ + target: 'postgres', + targetFamily: 'sql', + capabilities: { returning: { enabled: true } }, + domain: { + namespaces: { + public: { models: { User: model('users', { id: 'id', email: 'email_addr' }) } }, + auth: { models: { User: model('users', { id: 'id', token: 'token_col' }) } }, + }, + }, + storage: { + storageHash: 'stub', + namespaces: { + public: { + id: 'public', + entries: { table: { users: storageTable({ id: 'pg/int4@1', email_addr: 'pg/text@1' }) } }, + }, + auth: { + id: 'auth', + entries: { table: { users: storageTable({ id: 'pg/int4@1', token_col: 'pg/varchar@1' }) } }, + }, + }, + }, +}); + +type AggregateBuilderView = { + count(): unknown; + max(field: string): unknown; +}; +type WhereScoped = { + deleteAll(): { toArray(): Promise[]> }; + update(data: Record): Promise | null>; + delete(): Promise | null>; +}; +type CrudCollection = { + all(): { toArray(): Promise[]> }; + create(values: Record): Promise>; + where(filter: Record): WhereScoped; + upsert(input: { + create: Record; + update: Record; + }): Promise>; + aggregate( + fn: (aggregate: AggregateBuilderView) => Record, + ): Promise>; + groupBy(field: string): { + aggregate( + fn: (aggregate: AggregateBuilderView) => Record, + ): Promise[]>; + }; +}; +type TwoNamespaceOrm = { public: { User: CrudCollection }; auth: { User: CrudCollection } }; + +function setup(): { db: TwoNamespaceOrm; runtime: MockRuntime } { + const runtime = createMockRuntime(); + const db = blindCast( + orm({ + runtime, + context: blindCast>, 'stub execution context'>({ + contract: twoNamespaceContract, + applyMutationDefaults: () => [], + codecDescriptors: { descriptorFor: () => ({ traits: ['equality'] }) }, + }), + }), + ); + return { db, runtime }; +} + +function lastPlanAst(runtime: MockRuntime): SelectAst { + const plan = runtime.executions[runtime.executions.length - 1]?.plan; + return blindCast((plan as { ast: unknown }).ast); +} + +function projectionItems(ast: SelectAst): ProjectionItem[] { + return blindCast( + (ast as unknown as { projection: ProjectionItem[] }).projection, + ); +} + +function projectedColumns(ast: SelectAst): string[] { + return projectionItems(ast).map((item) => { + const expr = blindCast( + (item as unknown as { expr: unknown }).expr, + ); + return (expr as unknown as { column: string }).column; + }); +} + +function codecByColumn(ast: SelectAst): Record { + return codecByColumnOfProjection(projectionItems(ast)); +} + +function codecByColumnOfProjection( + projection: readonly ProjectionItem[], +): Record { + const result: Record = {}; + for (const item of projection) { + const column = ( + blindCast( + (item as unknown as { expr: unknown }).expr, + ) as unknown as { column: string } + ).column; + result[column] = (item as unknown as { codec?: { codecId: string } }).codec?.codecId; + } + return result; +} + +type WriteAst = { + table: TableSource; + returning?: readonly ProjectionItem[]; +}; + +function lastWriteAst(runtime: MockRuntime): WriteAst { + const plan = runtime.executions[runtime.executions.length - 1]?.plan; + return blindCast((plan as { ast: unknown }).ast); +} + +function returningCodecByColumn(ast: WriteAst): Record { + return codecByColumnOfProjection(ast.returning ?? []); +} + +function codecByAlias(ast: SelectAst): Record { + const result: Record = {}; + for (const item of projectionItems(ast)) { + const alias = (item as unknown as { alias: string }).alias; + result[alias] = (item as unknown as { codec?: { codecId: string } }).codec?.codecId; + } + return result; +} + +describe('orm same bare table name across namespaces', () => { + it('projects the per-namespace columns, discriminating by namespace', async () => { + const { db, runtime } = setup(); + + runtime.setNextResults([[{ id: 1, email_addr: 'a@example.com' }]]); + const publicRows = await db.public.User.all().toArray(); + expect(publicRows).toEqual([{ id: 1, email: 'a@example.com' }]); + const publicAst = lastPlanAst(runtime); + expect(projectedColumns(publicAst).sort()).toEqual(['email_addr', 'id']); + expect(codecByColumn(publicAst)).toEqual({ id: 'pg/int4@1', email_addr: 'pg/text@1' }); + expect((publicAst as unknown as { from: { namespaceId: string } }).from.namespaceId).toBe( + 'public', + ); + + runtime.setNextResults([[{ id: 2, token_col: 'tok' }]]); + const authRows = await db.auth.User.all().toArray(); + expect(authRows).toEqual([{ id: 2, token: 'tok' }]); + const authAst = lastPlanAst(runtime); + expect(projectedColumns(authAst).sort()).toEqual(['id', 'token_col']); + expect(codecByColumn(authAst)).toEqual({ id: 'pg/int4@1', token_col: 'pg/varchar@1' }); + expect((authAst as unknown as { from: { namespaceId: string } }).from.namespaceId).toBe('auth'); + }); + + it('resolves per-namespace returning columns/codecs on create, discriminating by namespace', async () => { + const { db, runtime } = setup(); + + runtime.setNextResults([[{ id: 1, email_addr: 'a@example.com' }]]); + const publicCreated = await db.public.User.create({ id: 1, email: 'a@example.com' }); + expect(publicCreated).toEqual({ id: 1, email: 'a@example.com' }); + const publicCreateAst = lastWriteAst(runtime); + expect(publicCreateAst.table.namespaceId).toBe('public'); + expect(returningCodecByColumn(publicCreateAst)).toEqual({ + id: 'pg/int4@1', + email_addr: 'pg/text@1', + }); + + runtime.setNextResults([[{ id: 2, token_col: 'tok' }]]); + const authCreated = await db.auth.User.create({ id: 2, token: 'tok' }); + expect(authCreated).toEqual({ id: 2, token: 'tok' }); + const authCreateAst = lastWriteAst(runtime); + expect(authCreateAst.table.namespaceId).toBe('auth'); + expect(returningCodecByColumn(authCreateAst)).toEqual({ + id: 'pg/int4@1', + token_col: 'pg/varchar@1', + }); + }); + + it('resolves per-namespace returning columns/codecs on delete, discriminating by namespace', async () => { + const { db, runtime } = setup(); + + runtime.setNextResults([[{ id: 1, email_addr: 'a@example.com' }]]); + const publicDeleted = await db.public.User.where({ email: 'a@example.com' }) + .deleteAll() + .toArray(); + expect(publicDeleted).toEqual([{ id: 1, email: 'a@example.com' }]); + const publicDeleteAst = lastWriteAst(runtime); + expect(publicDeleteAst.table.namespaceId).toBe('public'); + expect(returningCodecByColumn(publicDeleteAst)).toEqual({ + id: 'pg/int4@1', + email_addr: 'pg/text@1', + }); + + runtime.setNextResults([[{ id: 2, token_col: 'tok' }]]); + const authDeleted = await db.auth.User.where({ token: 'tok' }).deleteAll().toArray(); + expect(authDeleted).toEqual([{ id: 2, token: 'tok' }]); + const authDeleteAst = lastWriteAst(runtime); + expect(authDeleteAst.table.namespaceId).toBe('auth'); + expect(returningCodecByColumn(authDeleteAst)).toEqual({ + id: 'pg/int4@1', + token_col: 'pg/varchar@1', + }); + }); + + it('resolves identity columns within the namespace for singular update, discriminating by namespace', async () => { + const { db, runtime } = setup(); + + runtime.setNextResults([[{ id: 1 }], [{ id: 1, email_addr: 'b@example.com' }]]); + const publicUpdated = await db.public.User.where({ id: 1 }).update({ email: 'b@example.com' }); + expect(publicUpdated).toEqual({ id: 1, email: 'b@example.com' }); + const publicAst = lastWriteAst(runtime); + expect(publicAst.table.namespaceId).toBe('public'); + expect(returningCodecByColumn(publicAst)).toEqual({ id: 'pg/int4@1', email_addr: 'pg/text@1' }); + + runtime.setNextResults([[{ id: 2 }], [{ id: 2, token_col: 'tok2' }]]); + const authUpdated = await db.auth.User.where({ id: 2 }).update({ token: 'tok2' }); + expect(authUpdated).toEqual({ id: 2, token: 'tok2' }); + const authAst = lastWriteAst(runtime); + expect(authAst.table.namespaceId).toBe('auth'); + expect(returningCodecByColumn(authAst)).toEqual({ id: 'pg/int4@1', token_col: 'pg/varchar@1' }); + }); + + it('resolves identity columns within the namespace for singular delete, discriminating by namespace', async () => { + const { db, runtime } = setup(); + + runtime.setNextResults([[{ id: 1 }], [{ id: 1, email_addr: 'a@example.com' }]]); + const publicDeleted = await db.public.User.where({ id: 1 }).delete(); + expect(publicDeleted).toEqual({ id: 1, email: 'a@example.com' }); + const publicAst = lastWriteAst(runtime); + expect(publicAst.table.namespaceId).toBe('public'); + expect(returningCodecByColumn(publicAst)).toEqual({ id: 'pg/int4@1', email_addr: 'pg/text@1' }); + + runtime.setNextResults([[{ id: 2 }], [{ id: 2, token_col: 'tok' }]]); + const authDeleted = await db.auth.User.where({ id: 2 }).delete(); + expect(authDeleted).toEqual({ id: 2, token: 'tok' }); + const authAst = lastWriteAst(runtime); + expect(authAst.table.namespaceId).toBe('auth'); + expect(returningCodecByColumn(authAst)).toEqual({ id: 'pg/int4@1', token_col: 'pg/varchar@1' }); + }); + + it('resolves the PK-default conflict target within the namespace for upsert, discriminating by namespace', async () => { + const { db, runtime } = setup(); + + runtime.setNextResults([[{ id: 1, email_addr: 'a@example.com' }]]); + const publicUpserted = await db.public.User.upsert({ + create: { id: 1, email: 'a@example.com' }, + update: { email: 'a@example.com' }, + }); + expect(publicUpserted).toEqual({ id: 1, email: 'a@example.com' }); + const publicAst = lastWriteAst(runtime); + expect(publicAst.table.namespaceId).toBe('public'); + expect(returningCodecByColumn(publicAst)).toEqual({ id: 'pg/int4@1', email_addr: 'pg/text@1' }); + + runtime.setNextResults([[{ id: 2, token_col: 'tok' }]]); + const authUpserted = await db.auth.User.upsert({ + create: { id: 2, token: 'tok' }, + update: { token: 'tok' }, + }); + expect(authUpserted).toEqual({ id: 2, token: 'tok' }); + const authAst = lastWriteAst(runtime); + expect(authAst.table.namespaceId).toBe('auth'); + expect(returningCodecByColumn(authAst)).toEqual({ id: 'pg/int4@1', token_col: 'pg/varchar@1' }); + }); + + it('resolves per-namespace aggregate column codecs, discriminating by namespace', async () => { + const { db, runtime } = setup(); + + runtime.setNextResults([[{ maxEmail: 'z@example.com' }]]); + await db.public.User.aggregate((aggregate) => ({ maxEmail: aggregate.max('email') })); + const publicAst = lastPlanAst(runtime); + expect((publicAst as unknown as { from: { namespaceId: string } }).from.namespaceId).toBe( + 'public', + ); + expect(codecByAlias(publicAst)).toEqual({ maxEmail: 'pg/text@1' }); + + runtime.setNextResults([[{ maxToken: 'tok' }]]); + await db.auth.User.aggregate((aggregate) => ({ maxToken: aggregate.max('token') })); + const authAst = lastPlanAst(runtime); + expect((authAst as unknown as { from: { namespaceId: string } }).from.namespaceId).toBe('auth'); + expect(codecByAlias(authAst)).toEqual({ maxToken: 'pg/varchar@1' }); + }); + + it('resolves per-namespace grouped aggregate column codecs, discriminating by namespace', async () => { + const { db, runtime } = setup(); + + runtime.setNextResults([[{ id: 1, maxEmail: 'z@example.com' }]]); + await db.public.User.groupBy('id').aggregate((aggregate) => ({ + maxEmail: aggregate.max('email'), + })); + const publicAst = lastPlanAst(runtime); + expect((publicAst as unknown as { from: { namespaceId: string } }).from.namespaceId).toBe( + 'public', + ); + expect(codecByAlias(publicAst)).toEqual({ id: 'pg/int4@1', maxEmail: 'pg/text@1' }); + + runtime.setNextResults([[{ id: 2, maxToken: 'tok' }]]); + await db.auth.User.groupBy('id').aggregate((aggregate) => ({ + maxToken: aggregate.max('token'), + })); + const authAst = lastPlanAst(runtime); + expect((authAst as unknown as { from: { namespaceId: string } }).from.namespaceId).toBe('auth'); + expect(codecByAlias(authAst)).toEqual({ id: 'pg/int4@1', maxToken: 'pg/varchar@1' }); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/orm.test.ts b/packages/3-extensions/sql-orm-client/test/orm.test.ts index ee7fb4e392..9eaaf6762b 100644 --- a/packages/3-extensions/sql-orm-client/test/orm.test.ts +++ b/packages/3-extensions/sql-orm-client/test/orm.test.ts @@ -145,7 +145,9 @@ describe('orm()', () => { it('throws when custom collection values are instances instead of classes', () => { const runtime = createMockRuntime(); - const postCollectionInstance = new PostCollection({ runtime, context }, 'Post'); + const postCollectionInstance = new PostCollection({ runtime, context }, 'Post', { + namespaceId: 'public', + }); expect(() => orm({ diff --git a/packages/3-extensions/sql-orm-client/test/orm.types.test-d.ts b/packages/3-extensions/sql-orm-client/test/orm.types.test-d.ts index 9d6cf753a0..55b837af06 100644 --- a/packages/3-extensions/sql-orm-client/test/orm.types.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/orm.types.test-d.ts @@ -29,8 +29,10 @@ db.Post.published(); orm({ runtime, context, - // @ts-expect-error collections values must be classes, not instances - collections: { User: new UserCollection({ runtime, context }, 'User') }, + collections: { + // @ts-expect-error collections values must be classes, not instances + User: new UserCollection({ runtime, context }, 'User', { namespaceId: 'public' }), + }, }); // --------------------------------------------------------------------------- diff --git a/packages/3-extensions/sql-orm-client/test/query-plan-aggregate.test.ts b/packages/3-extensions/sql-orm-client/test/query-plan-aggregate.test.ts index c0bdd62898..e659a74b29 100644 --- a/packages/3-extensions/sql-orm-client/test/query-plan-aggregate.test.ts +++ b/packages/3-extensions/sql-orm-client/test/query-plan-aggregate.test.ts @@ -30,7 +30,15 @@ const defaultAggSpec = { }; function compileWithHaving(having: AnyExpression) { - return compileGroupedAggregate(baseContract, 'posts', [], ['user_id'], defaultAggSpec, having); + return compileGroupedAggregate( + baseContract, + 'public', + 'posts', + [], + ['user_id'], + defaultAggSpec, + having, + ); } describe('query plan aggregate', () => { @@ -40,11 +48,11 @@ describe('query plan aggregate', () => { ); it('rejects empty aggregate specs and selectors without required fields', () => { - expect(() => compileAggregate(baseContract, 'posts', [], {})).toThrow( + expect(() => compileAggregate(baseContract, 'public', 'posts', [], {})).toThrow( 'aggregate() requires at least one aggregation selector', ); expect(() => - compileAggregate(baseContract, 'posts', [], { + compileAggregate(baseContract, 'public', 'posts', [], { totalViews: { kind: 'aggregate', fn: 'sum' }, }), ).toThrow('Aggregate selector "sum" requires a field'); @@ -52,6 +60,7 @@ describe('query plan aggregate', () => { expect(() => compileGroupedAggregate( baseContract, + 'public', 'posts', [], [], @@ -63,7 +72,7 @@ describe('query plan aggregate', () => { ).toThrow('groupBy() requires at least one field'); expect(() => - compileGroupedAggregate(baseContract, 'posts', [], ['user_id'], {}, undefined), + compileGroupedAggregate(baseContract, 'public', 'posts', [], ['user_id'], {}, undefined), ).toThrow('groupBy().aggregate() requires at least one aggregation selector'); }); @@ -75,6 +84,7 @@ describe('query plan aggregate', () => { expect(() => compileGroupedAggregate( baseContract, + 'public', 'posts', [], ['user_id'], @@ -89,6 +99,7 @@ describe('query plan aggregate', () => { expect(() => compileGroupedAggregate( baseContract, + 'public', 'posts', [], ['user_id'], @@ -103,6 +114,7 @@ describe('query plan aggregate', () => { expect(() => compileGroupedAggregate( baseContract, + 'public', 'posts', [], ['user_id'], @@ -114,6 +126,7 @@ describe('query plan aggregate', () => { expect(() => compileGroupedAggregate( baseContract, + 'public', 'posts', [], ['user_id'], @@ -126,6 +139,7 @@ describe('query plan aggregate', () => { it('keeps grouped aggregate HAVING expressions composed from aggregate metrics', () => { const plan = compileGroupedAggregate( baseContract, + 'public', 'posts', [], ['user_id'], @@ -159,6 +173,7 @@ describe('query plan aggregate', () => { it('keeps grouped aggregate HAVING with OR expressions', () => { const plan = compileGroupedAggregate( baseContract, + 'public', 'posts', [], ['user_id'], @@ -181,7 +196,7 @@ describe('query plan aggregate', () => { }); it('keeps aggregate filters and params when lowering plain aggregate queries', () => { - const plan = compileAggregate(baseContract, 'posts', [filteredViews], { + const plan = compileAggregate(baseContract, 'public', 'posts', [filteredViews], { totalViews: { kind: 'aggregate', fn: 'sum', column: 'views' }, }); @@ -197,7 +212,7 @@ describe('query plan aggregate', () => { }); it('stamps min/max ProjectionItem.codec from the underlying column', () => { - const plan = compileAggregate(baseContract, 'posts', [], { + const plan = compileAggregate(baseContract, 'public', 'posts', [], { minViews: { kind: 'aggregate', fn: 'min', column: 'views' }, maxViews: { kind: 'aggregate', fn: 'max', column: 'views' }, }); @@ -209,7 +224,7 @@ describe('query plan aggregate', () => { }); it('leaves count/sum/avg ProjectionItem.codec undefined (deferred until target+widening-aware mapping)', () => { - const plan = compileAggregate(baseContract, 'posts', [], { + const plan = compileAggregate(baseContract, 'public', 'posts', [], { total: { kind: 'aggregate', fn: 'count' }, sumViews: { kind: 'aggregate', fn: 'sum', column: 'views' }, avgViews: { kind: 'aggregate', fn: 'avg', column: 'views' }, @@ -223,6 +238,7 @@ describe('query plan aggregate', () => { it('stamps min/max codec on grouped aggregates too', () => { const plan = compileGroupedAggregate( baseContract, + 'public', 'posts', [], ['user_id'], diff --git a/packages/3-extensions/sql-orm-client/test/query-plan-meta.test.ts b/packages/3-extensions/sql-orm-client/test/query-plan-meta.test.ts index d0ca0aeaff..d5a85829d9 100644 --- a/packages/3-extensions/sql-orm-client/test/query-plan-meta.test.ts +++ b/packages/3-extensions/sql-orm-client/test/query-plan-meta.test.ts @@ -16,10 +16,10 @@ import { unboundTables } from './unbound-tables'; describe('query plan meta', () => { it('resolves table columns and rejects unknown tables', () => { - expect(resolveTableColumns(baseContract, 'users')).toEqual( + expect(resolveTableColumns(baseContract, 'public', 'users')).toEqual( Object.keys(unboundTables(baseContract.storage)['users']!.columns), ); - expect(() => resolveTableColumns(baseContract, 'missing')).toThrow( + expect(() => resolveTableColumns(baseContract, 'public', 'missing')).toThrow( 'Unknown table "missing" in SQL ORM query planner', ); }); diff --git a/packages/3-extensions/sql-orm-client/test/query-plan-mutations.test.ts b/packages/3-extensions/sql-orm-client/test/query-plan-mutations.test.ts index 8a5c17f7e6..4c62999453 100644 --- a/packages/3-extensions/sql-orm-client/test/query-plan-mutations.test.ts +++ b/packages/3-extensions/sql-orm-client/test/query-plan-mutations.test.ts @@ -48,6 +48,7 @@ describe('query plan mutations', () => { const contract = withReturningCapability(getTestContract()); const plan = compileInsertReturning( contract, + 'public', 'users', [ { id: 10, name: 'Alice', email: 'alice@example.com' }, @@ -91,7 +92,7 @@ describe('query plan mutations', () => { it('compileInsertCount() keeps explicit empty rows for all-default batch inserts', () => { const contract = getTestContract(); - const plan = compileInsertCount(contract, 'users', [{}, {}]); + const plan = compileInsertCount(contract, 'public', 'users', [{}, {}]); assertInsertAst(plan.ast); expect(plan.params).toEqual([]); @@ -102,6 +103,7 @@ describe('query plan mutations', () => { const contract = withReturningCapability(getTestContract()); const plan = compileUpsertReturning( contract, + 'public', 'users', { id: 10, name: 'Alice', email: 'alice@example.com' }, {}, @@ -120,7 +122,7 @@ describe('query plan mutations', () => { it('compileInsertReturning() rejects empty rows array', () => { const contract = withReturningCapability(getTestContract()); - expect(() => compileInsertReturning(contract, 'users', [], undefined)).toThrow( + expect(() => compileInsertReturning(contract, 'public', 'users', [], undefined)).toThrow( 'at least one row', ); }); @@ -128,13 +130,14 @@ describe('query plan mutations', () => { it('compileInsertCount() rejects empty rows array', () => { const contract = getTestContract(); - expect(() => compileInsertCount(contract, 'users', [])).toThrow('at least one row'); + expect(() => compileInsertCount(contract, 'public', 'users', [])).toThrow('at least one row'); }); it('compileUpsertReturning() produces DoUpdateSetConflictAction with correct params when update is non-empty', () => { const contract = withReturningCapability(getTestContract()); const plan = compileUpsertReturning( contract, + 'public', 'users', { id: 10, name: 'Alice', email: 'alice@example.com' }, { name: 'Updated Alice' }, @@ -156,6 +159,7 @@ describe('query plan mutations', () => { const contract = withReturningCapability(getTestContract()); const plans = compileInsertReturningSplit( contract, + 'public', 'users', [ { id: 1, name: 'Alice', email: 'a@a.com' }, @@ -172,6 +176,7 @@ describe('query plan mutations', () => { const contract = withReturningCapability(getTestContract()); const plans = compileInsertReturningSplit( contract, + 'public', 'users', [ { id: 1, name: 'Alice', email: 'a@a.com' }, @@ -190,6 +195,7 @@ describe('query plan mutations', () => { const contract = withReturningCapability(getTestContract()); const plans = compileInsertReturningSplit( contract, + 'public', 'users', [ { id: 1, name: 'Alice', email: 'a@a.com' }, @@ -205,6 +211,7 @@ describe('query plan mutations', () => { const contract = withReturningCapability(getTestContract()); const plans = compileInsertReturningSplit( contract, + 'public', 'users', [ { id: 1, name: 'Alice', email: 'a@a.com' }, @@ -225,6 +232,7 @@ describe('query plan mutations', () => { const contract = withReturningCapability(getTestContract()); const plans = compileInsertReturningSplit( contract, + 'public', 'users', [ { id: 1, name: 'Alice', email: 'a@a.com', invited_by_id: undefined }, @@ -241,6 +249,7 @@ describe('query plan mutations', () => { const contract = withReturningCapability(getTestContract()); const plans = compileInsertReturningSplit( contract, + 'public', 'users', [{ id: 1, name: 'Alice', email: 'a@a.com' }], undefined, @@ -254,7 +263,7 @@ describe('query plan mutations', () => { describe('compileInsertCountSplit()', () => { it('produces a single plan when all rows have the same columns', () => { const contract = getTestContract(); - const plans = compileInsertCountSplit(contract, 'users', [ + const plans = compileInsertCountSplit(contract, 'public', 'users', [ { id: 1, name: 'Alice', email: 'a@a.com' }, { id: 2, name: 'Bob', email: 'b@b.com' }, ]); @@ -263,7 +272,7 @@ describe('query plan mutations', () => { it('splits rows with different column sets', () => { const contract = getTestContract(); - const plans = compileInsertCountSplit(contract, 'users', [ + const plans = compileInsertCountSplit(contract, 'public', 'users', [ { id: 1, name: 'Alice', email: 'a@a.com' }, { id: 2, name: 'Bob', email: 'b@b.com', invited_by_id: 1 }, ]); @@ -272,7 +281,7 @@ describe('query plan mutations', () => { it('preserves input order over minimizing group count', () => { const contract = getTestContract(); - const plans = compileInsertCountSplit(contract, 'users', [ + const plans = compileInsertCountSplit(contract, 'public', 'users', [ { id: 1, name: 'A', email: 'a@a.com' }, { id: 2, name: 'B', email: 'b@b.com', invited_by_id: 1 }, { id: 3, name: 'C', email: 'c@c.com' }, @@ -284,12 +293,12 @@ describe('query plan mutations', () => { it('compileUpdateCount() and compileDeleteCount() omit WHERE when filters are empty', () => { const contract = getTestContract(); - const updatePlan = compileUpdateCount(contract, 'users', { name: 'Alice' }, []); + const updatePlan = compileUpdateCount(contract, 'public', 'users', { name: 'Alice' }, []); expect(updatePlan.ast.kind).toBe('update'); expect((updatePlan.ast as UpdateAst).where).toBeUndefined(); expect(updatePlan.params).toEqual(['Alice']); - const deletePlan = compileDeleteCount(contract, 'users', []); + const deletePlan = compileDeleteCount(contract, 'public', 'users', []); expect(deletePlan.ast.kind).toBe('delete'); expect((deletePlan.ast as DeleteAst).where).toBeUndefined(); expect(deletePlan.params).toEqual([]); @@ -298,14 +307,16 @@ describe('query plan mutations', () => { describe('split helpers reject empty rows', () => { it('compileInsertReturningSplit() rejects an empty rows array', () => { const contract = withReturningCapability(getTestContract()); - expect(() => compileInsertReturningSplit(contract, 'users', [], undefined)).toThrowError( - /at least one row/, - ); + expect(() => + compileInsertReturningSplit(contract, 'public', 'users', [], undefined), + ).toThrowError(/at least one row/); }); it('compileInsertCountSplit() rejects an empty rows array', () => { const contract = getTestContract(); - expect(() => compileInsertCountSplit(contract, 'users', [])).toThrowError(/at least one row/); + expect(() => compileInsertCountSplit(contract, 'public', 'users', [])).toThrowError( + /at least one row/, + ); }); }); @@ -324,6 +335,7 @@ describe('query plan mutations', () => { const contract = withReturningCapability(getTestContract()); const plan = compileUpdateReturning( contract, + 'public', 'users', { name: 'Alice' }, [eqOnUserId(7)], @@ -336,18 +348,26 @@ describe('query plan mutations', () => { it('compileUpdateCount() preserves WHERE when filters are present', () => { const contract = getTestContract(); - const plan = compileUpdateCount(contract, 'users', { name: 'Bob' }, [eqOnUserId(9)]); + const plan = compileUpdateCount(contract, 'public', 'users', { name: 'Bob' }, [ + eqOnUserId(9), + ]); expect((plan.ast as UpdateAst).where).toBeDefined(); expect(plan.params).toEqual(['Bob', 9]); }); it('compileDeleteReturning() preserves WHERE when filters are present and omits when empty', () => { const contract = withReturningCapability(getTestContract()); - const planWithWhere = compileDeleteReturning(contract, 'users', [eqOnUserId(3)], undefined); + const planWithWhere = compileDeleteReturning( + contract, + 'public', + 'users', + [eqOnUserId(3)], + undefined, + ); expect((planWithWhere.ast as DeleteAst).where).toBeDefined(); expect(planWithWhere.params).toEqual([3]); - const planNoWhere = compileDeleteReturning(contract, 'users', [], undefined); + const planNoWhere = compileDeleteReturning(contract, 'public', 'users', [], undefined); expect((planNoWhere.ast as DeleteAst).where).toBeUndefined(); expect(planNoWhere.params).toEqual([]); }); @@ -356,29 +376,29 @@ describe('query plan mutations', () => { describe('table/column resolution errors', () => { it('compileUpdateCount() rejects an unknown table', () => { const contract = getTestContract(); - expect(() => compileUpdateCount(contract, 'missing_table', { name: 'X' }, [])).toThrowError( - /Unknown table "missing_table"/, - ); + expect(() => + compileUpdateCount(contract, 'public', 'missing_table', { name: 'X' }, []), + ).toThrowError(/Unknown table "missing_table"/); }); it('compileUpdateCount() rejects an unknown column for the table', () => { const contract = getTestContract(); expect(() => - compileUpdateCount(contract, 'users', { not_a_real_column: 'X' }, []), + compileUpdateCount(contract, 'public', 'users', { not_a_real_column: 'X' }, []), ).toThrowError(/Unknown column "not_a_real_column" in table "users"/); }); it('compileInsertCount() rejects an unknown table', () => { const contract = getTestContract(); - expect(() => compileInsertCount(contract, 'missing_table', [{ id: 1 }])).toThrowError( - /Unknown table "missing_table"/, - ); + expect(() => + compileInsertCount(contract, 'public', 'missing_table', [{ id: 1 }]), + ).toThrowError(/Unknown table "missing_table"/); }); it('compileInsertCount() rejects an unknown column on an insert row', () => { const contract = getTestContract(); expect(() => - compileInsertCount(contract, 'users', [{ id: 1, not_a_real_column: 'X' }]), + compileInsertCount(contract, 'public', 'users', [{ id: 1, not_a_real_column: 'X' }]), ).toThrowError(/Unknown column "not_a_real_column" in table "users"/); }); }); diff --git a/packages/3-extensions/sql-orm-client/test/query-plan-select.test.ts b/packages/3-extensions/sql-orm-client/test/query-plan-select.test.ts index eff35fcc0d..40f9a86153 100644 --- a/packages/3-extensions/sql-orm-client/test/query-plan-select.test.ts +++ b/packages/3-extensions/sql-orm-client/test/query-plan-select.test.ts @@ -67,7 +67,7 @@ describe('compileSelectWithIncludes', () => { .where((user) => user.name.eq('Alice')) .include('posts', (posts) => posts.where((post) => post.views.gte(100))).state; - const plan = compileSelectWithIncludes(baseContract, 'users', state); + const plan = compileSelectWithIncludes(baseContract, 'public', 'users', state); expect(plan.params).toEqual([100, 'Alice']); expect(paramCodecs(plan)).toEqual([ codecForColumn('posts', 'views'), @@ -112,7 +112,7 @@ describe('compileSelectWithIncludes', () => { .skip(3) .select('id').state; - const plan = compileSelect(baseContract, 'users', state); + const plan = compileSelect(baseContract, 'public', 'users', state); expectSelectAst(plan.ast); expect(plan.params).toEqual(['Alice', 'Alice', 7]); expect(paramCodecs(plan)).toEqual([ @@ -144,7 +144,7 @@ describe('compileSelectWithIncludes', () => { const { collection } = createCollection(); const state = collection.orderBy((user) => user.id.asc()).cursor({ id: 9 }).state; - const plan = compileSelect(baseContract, 'users', state); + const plan = compileSelect(baseContract, 'public', 'users', state); expectSelectAst(plan.ast); expect(plan.params).toEqual([9]); expect(paramCodecs(plan)).toEqual([codecForColumn('users', 'id')]); @@ -156,7 +156,7 @@ describe('compileSelectWithIncludes', () => { ...collection.orderBy((user) => user.id.asc()).state, cursor: {}, }; - expect(() => compileSelect(baseContract, 'users', invalidState)).toThrow( + expect(() => compileSelect(baseContract, 'public', 'users', invalidState)).toThrow( 'Missing cursor value for orderBy column "id"', ); }); @@ -176,7 +176,7 @@ describe('compileSelectWithIncludes', () => { orderBy: [OrderByItem.asc(ColumnRef.of('posts', 'id')), OrderByItem.desc(opExpr)], }; - const plan = compileSelect(baseContract, 'posts', state); + const plan = compileSelect(baseContract, 'public', 'posts', state); expectSelectAst(plan.ast); expect(plan.ast.orderBy).toEqual([ @@ -206,7 +206,7 @@ describe('compileSelectWithIncludes', () => { cursor: { id: 5 }, }; - const plan = compileSelect(baseContract, 'posts', state); + const plan = compileSelect(baseContract, 'public', 'posts', state); expectSelectAst(plan.ast); expect(plan.ast.orderBy).toEqual([ @@ -236,7 +236,7 @@ describe('compileSelectWithIncludes', () => { filters: [whereExpr], }; - const plan = compileSelect(baseContract, 'posts', state); + const plan = compileSelect(baseContract, 'public', 'posts', state); expectSelectAst(plan.ast); expect(plan.params).toEqual([[1, 2, 3]]); @@ -274,7 +274,7 @@ describe('compileSelectWithIncludes', () => { orderBy: [OrderByItem.asc(ColumnRef.of('posts', 'id')), OrderByItem.asc(orderOpExpr)], }; - const plan = compileSelect(baseContract, 'posts', state); + const plan = compileSelect(baseContract, 'public', 'posts', state); expectSelectAst(plan.ast); expect(plan.ast.orderBy).toEqual([ @@ -298,7 +298,7 @@ describe('compileSelectWithIncludes', () => { .take(2), ).state; - const plan = compileSelectWithIncludes(baseContract, 'users', state); + const plan = compileSelectWithIncludes(baseContract, 'public', 'users', state); expectSelectAst(plan.ast); expect(plan.ast.joins ?? []).toHaveLength(0); @@ -347,7 +347,7 @@ describe('compileSelectWithIncludes', () => { const { collection } = createCollection(); const state = collection.include('posts', (posts) => posts.count()).state; - const plan = compileSelectWithIncludes(baseContract, 'users', state); + const plan = compileSelectWithIncludes(baseContract, 'public', 'users', state); const subquery = extractScalarCorrelatedSubquery(plan, 'posts'); expectAggregateProjection(subquery, 'posts', AggregateExpr.count()); @@ -366,7 +366,7 @@ describe('compileSelectWithIncludes', () => { posts.where((post) => post.views.gte(100)).count(), ).state; - const plan = compileSelectWithIncludes(baseContract, 'users', state); + const plan = compileSelectWithIncludes(baseContract, 'public', 'users', state); const subquery = extractScalarCorrelatedSubquery(plan, 'posts'); expectAggregateProjection(subquery, 'posts', AggregateExpr.count()); @@ -390,7 +390,7 @@ describe('compileSelectWithIncludes', () => { posts.orderBy((post) => post.id.asc()).count(), ).state; - const plan = compileSelectWithIncludes(baseContract, 'users', state); + const plan = compileSelectWithIncludes(baseContract, 'public', 'users', state); const subquery = extractScalarCorrelatedSubquery(plan, 'posts'); expect(subquery.orderBy).toBeUndefined(); }); @@ -407,7 +407,7 @@ describe('compileSelectWithIncludes', () => { .count(), ).state; - const plan = compileSelectWithIncludes(baseContract, 'users', state); + const plan = compileSelectWithIncludes(baseContract, 'public', 'users', state); const subquery = extractScalarCorrelatedSubquery(plan, 'posts'); expectAggregateProjection(subquery, 'posts', AggregateExpr.count()); @@ -445,7 +445,7 @@ describe('compileSelectWithIncludes', () => { .sum('views'), ).state; - const plan = compileSelectWithIncludes(baseContract, 'users', state); + const plan = compileSelectWithIncludes(baseContract, 'public', 'users', state); const subquery = extractScalarCorrelatedSubquery(plan, 'posts'); expectAggregateProjection( @@ -475,7 +475,7 @@ describe('compileSelectWithIncludes', () => { for (const [fn, expected] of reducers) { const { collection } = createCollection(); const state = collection.include('posts', (posts) => posts[fn]('views')).state; - const plan = compileSelectWithIncludes(baseContract, 'users', state); + const plan = compileSelectWithIncludes(baseContract, 'public', 'users', state); const subquery = extractScalarCorrelatedSubquery(plan, 'posts'); expectAggregateProjection(subquery, 'posts', expected); } @@ -489,7 +489,7 @@ describe('compileSelectWithIncludes', () => { posts.include('comments', (comments) => comments.count()), ).state; - const plan = compileSelectWithIncludes(baseContract, 'users', state); + const plan = compileSelectWithIncludes(baseContract, 'public', 'users', state); const postsSubquery = extractScalarCorrelatedSubquery(plan, 'posts'); // The posts subquery's FROM is the child-rows derived table; its // inner SELECT carries the nested comments correlated subquery as @@ -531,7 +531,7 @@ describe('compileSelectWithIncludes', () => { }), ).state; - const plan = compileSelectWithIncludes(baseContract, 'users', state); + const plan = compileSelectWithIncludes(baseContract, 'public', 'users', state); const subquery = extractCombineCorrelatedSubquery(plan, 'posts'); // Outer projection is json_build_object referencing per-branch @@ -566,7 +566,7 @@ describe('compileSelectWithIncludes', () => { }), ).state; - const plan = compileSelectWithIncludes(baseContract, 'users', state); + const plan = compileSelectWithIncludes(baseContract, 'public', 'users', state); const subquery = extractCombineCorrelatedSubquery(plan, 'posts'); expectDerivedTableSource(subquery.from); @@ -599,7 +599,7 @@ describe('compileSelectWithIncludes', () => { }), ).state; - const plan = compileSelectWithIncludes(baseContract, 'users', state); + const plan = compileSelectWithIncludes(baseContract, 'public', 'users', state); const subquery = extractCombineCorrelatedSubquery(plan, 'posts'); const fkExpr = BinaryExpr.eq(ColumnRef.of('posts', 'user_id'), ColumnRef.of('users', 'id')); @@ -684,7 +684,7 @@ describe('compileSelect MTI JOINs', () => { ), ]; - const plan = compileSelect(contract, 'tasks', emptyState(), 'Task'); + const plan = compileSelect(contract, 'public', 'tasks', emptyState(), 'Task'); expect(plan.ast).toEqual( SelectAst.from(TableSource.named('tasks', undefined, 'public')) @@ -715,7 +715,7 @@ describe('compileSelect MTI JOINs', () => { ), ]; - const plan = compileSelect(contract, 'tasks', state, 'Task'); + const plan = compileSelect(contract, 'public', 'tasks', state, 'Task'); expect(plan.ast).toEqual( SelectAst.from(TableSource.named('tasks', undefined, 'public')) @@ -739,7 +739,7 @@ describe('compileSelect MTI JOINs', () => { 'parent_id', ]); - const plan = compileSelect(contract, 'tasks', state, 'Task'); + const plan = compileSelect(contract, 'public', 'tasks', state, 'Task'); expect(plan.ast).toEqual( SelectAst.from(TableSource.named('tasks', undefined, 'public')) @@ -749,7 +749,7 @@ describe('compileSelect MTI JOINs', () => { }); it('non-polymorphic model produces no JOINs', () => { - const plan = compileSelect(baseContract, 'users', emptyState(), 'User'); + const plan = compileSelect(baseContract, 'public', 'users', emptyState(), 'User'); expect(plan.ast).toEqual( SelectAst.from(TableSource.named('users', undefined, 'public')) @@ -767,12 +767,14 @@ describe('compileSelectWithIncludes polymorphic targets', () => { parentModel: string, relationName: string, nested: CollectionState = emptyState(), + namespaceId = 'public', ): IncludeExpr { - const relation = resolveIncludeRelation(contract, parentModel, relationName); + const relation = resolveIncludeRelation(contract, namespaceId, parentModel, relationName); return { relationName, relatedModelName: relation.relatedModelName, relatedTableName: relation.relatedTableName, + relatedNamespaceId: relation.relatedNamespaceId, targetColumn: relation.targetColumn, localColumn: relation.localColumn, cardinality: relation.cardinality, @@ -803,7 +805,7 @@ describe('compileSelectWithIncludes polymorphic targets', () => { const contract = buildStiPolyContract(); const state = stateWithInclude(includeFor(contract, 'Account', 'members')); - const plan = compileSelectWithIncludes(contract, 'accounts', state, 'Account'); + const plan = compileSelectWithIncludes(contract, 'public', 'accounts', state, 'Account'); const childRows = childRowsSelectFor(plan, 'members'); expect(childRows.joins ?? []).toHaveLength(0); @@ -817,7 +819,7 @@ describe('compileSelectWithIncludes polymorphic targets', () => { const contract = buildMixedPolyContract(); const state = stateWithInclude(includeFor(contract, 'Project', 'tasks')); - const plan = compileSelectWithIncludes(contract, 'projects_tbl', state, 'Project'); + const plan = compileSelectWithIncludes(contract, 'public', 'projects_tbl', state, 'Project'); const childRows = childRowsSelectFor(plan, 'tasks'); expect(childRows.joins).toEqual([ @@ -841,7 +843,7 @@ describe('compileSelectWithIncludes polymorphic targets', () => { }); const state = stateWithInclude(include); - const plan = compileSelectWithIncludes(contract, 'projects_tbl', state, 'Project'); + const plan = compileSelectWithIncludes(contract, 'public', 'projects_tbl', state, 'Project'); const childRows = childRowsSelectFor(plan, 'tasks'); expect(childRows.joins).toEqual([ @@ -860,7 +862,7 @@ describe('compileSelectWithIncludes polymorphic targets', () => { // than the unaliased base table name. const state = stateWithInclude(includeFor(contract, 'Task', 'subtasks')); - const plan = compileSelectWithIncludes(contract, 'tasks', state, 'Task'); + const plan = compileSelectWithIncludes(contract, 'public', 'tasks', state, 'Task'); const childRows = childRowsSelectFor(plan, 'subtasks'); expect(childRows.joins).toEqual([ diff --git a/packages/3-extensions/sql-orm-client/test/repository.test.ts b/packages/3-extensions/sql-orm-client/test/repository.test.ts index da4bff7449..8ad92b5c92 100644 --- a/packages/3-extensions/sql-orm-client/test/repository.test.ts +++ b/packages/3-extensions/sql-orm-client/test/repository.test.ts @@ -14,14 +14,14 @@ describe('Collection construction', () => { it('resolves table name from contract mappings', () => { const runtime = createMockRuntime(); const context = getTestContext(); - const collection = new Collection({ runtime, context }, 'User'); + const collection = new Collection({ runtime, context }, 'User', { namespaceId: 'public' }); expect(collection.tableName).toBe('users'); }); it('initializes with empty state', () => { const runtime = createMockRuntime(); const context = getTestContext(); - const collection = new Collection({ runtime, context }, 'Post'); + const collection = new Collection({ runtime, context }, 'Post', { namespaceId: 'public' }); expect(collection.state.filters).toEqual([]); expect(collection.state.includes).toEqual([]); expect(collection.state.orderBy).toBeUndefined(); @@ -32,7 +32,7 @@ describe('Collection construction', () => { it('supports custom subclass with named scopes', () => { const runtime = createMockRuntime(); const context = getTestContext(); - const collection = new PostCollection({ runtime, context }, 'Post'); + const collection = new PostCollection({ runtime, context }, 'Post', { namespaceId: 'public' }); const scoped = collection.popular(); expect(scoped.state.filters).toHaveLength(1); expect(scoped.state.filters[0]).toEqual( diff --git a/packages/3-extensions/sql-orm-client/test/rich-filters-and-where.test.ts b/packages/3-extensions/sql-orm-client/test/rich-filters-and-where.test.ts index 570fb67b7f..1237d6984f 100644 --- a/packages/3-extensions/sql-orm-client/test/rich-filters-and-where.test.ts +++ b/packages/3-extensions/sql-orm-client/test/rich-filters-and-where.test.ts @@ -30,7 +30,7 @@ describe('SQL ORM rich AST filters', () => { const context = getTestContext(); it('builds scalar and relation filters as AST instances', () => { - const user = createModelAccessor(context, 'User'); + const user = createModelAccessor(context, 'public', 'User'); const expr = and( user['name']!.eq('Alice'), user['posts']!.some((post) => post['views']!.gt(10)), diff --git a/packages/3-extensions/sql-orm-client/test/rich-query-plans.test.ts b/packages/3-extensions/sql-orm-client/test/rich-query-plans.test.ts index db09f802a7..abe3e5532f 100644 --- a/packages/3-extensions/sql-orm-client/test/rich-query-plans.test.ts +++ b/packages/3-extensions/sql-orm-client/test/rich-query-plans.test.ts @@ -42,7 +42,7 @@ describe('SQL ORM rich AST query plans', () => { ) .take(5).state; - const plan = compileSelectWithIncludes(baseContract, 'users', state); + const plan = compileSelectWithIncludes(baseContract, 'public', 'users', state); expect(plan.ast.kind).toBe('select'); expect(plan.params).toEqual([100, 'Alice']); @@ -66,6 +66,7 @@ describe('SQL ORM rich AST query plans', () => { it('compiles insert, upsert, update, delete, and grouped aggregate plans with rich nodes', () => { const insertPlan = compileInsertReturning( baseContract, + 'public', 'users', [{ id: 1, name: 'Alice', email: 'a@example.com' }], ['id'], @@ -74,6 +75,7 @@ describe('SQL ORM rich AST query plans', () => { const upsertPlan = compileUpsertReturning( baseContract, + 'public', 'users', { id: 1, name: 'Alice', email: 'a@example.com' }, { name: 'Alice Updated' }, @@ -85,6 +87,7 @@ describe('SQL ORM rich AST query plans', () => { const updatePlan = compileUpdateReturning( baseContract, + 'public', 'users', { email: 'b@example.com' }, [BinaryExpr.eq(ColumnRef.of('users', 'id'), LiteralExpr.of(1))], @@ -95,6 +98,7 @@ describe('SQL ORM rich AST query plans', () => { const deletePlan = compileDeleteReturning( baseContract, + 'public', 'users', [BinaryExpr.eq(ColumnRef.of('users', 'id'), LiteralExpr.of(1))], ['id'], @@ -103,6 +107,7 @@ describe('SQL ORM rich AST query plans', () => { const groupedPlan = compileGroupedAggregate( baseContract, + 'public', 'posts', [], ['user_id'], diff --git a/packages/3-extensions/sql-orm-client/test/simplify-deep.test-d.ts b/packages/3-extensions/sql-orm-client/test/simplify-deep.test-d.ts index b3ea919d0e..a77f7f09d9 100644 --- a/packages/3-extensions/sql-orm-client/test/simplify-deep.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/simplify-deep.test-d.ts @@ -7,7 +7,7 @@ describe('Collection result types are simplified', () => { const context = getTestContext(); test('default Row is a plain object', () => { - const users = new Collection({ runtime, context }, 'User'); + const users = new Collection({ runtime, context }, 'User', { namespaceId: 'public' }); type UserRow = Awaited>; expectTypeOf>().toEqualTypeOf<{ id: number; @@ -23,7 +23,7 @@ describe('Collection result types are simplified', () => { }); test('select() produces a plain object', () => { - const users = new Collection({ runtime, context }, 'User'); + const users = new Collection({ runtime, context }, 'User', { namespaceId: 'public' }); const selected = users.select('id', 'email'); type SelectedRow = Awaited>; expectTypeOf>().toEqualTypeOf<{ @@ -33,7 +33,7 @@ describe('Collection result types are simplified', () => { }); test('include() produces a plain object with nested relation', () => { - const users = new Collection({ runtime, context }, 'User'); + const users = new Collection({ runtime, context }, 'User', { namespaceId: 'public' }); const withPosts = users.include('posts'); type WithPostsRow = Awaited>; expectTypeOf>().toEqualTypeOf<{ @@ -57,7 +57,7 @@ describe('Collection result types are simplified', () => { }); test('select().include() produces a plain object', () => { - const users = new Collection({ runtime, context }, 'User'); + const users = new Collection({ runtime, context }, 'User', { namespaceId: 'public' }); const selected = users.select('name').include('posts'); type Row = Awaited>; expectTypeOf>().toEqualTypeOf<{ @@ -73,7 +73,7 @@ describe('Collection result types are simplified', () => { }); test('include() with non-nullable to-one relation', () => { - const posts = new Collection({ runtime, context }, 'Post'); + const posts = new Collection({ runtime, context }, 'Post', { namespaceId: 'public' }); const withAuthor = posts.include('author'); type Row = Awaited>; type AuthorField = NonNullable['author']; @@ -91,7 +91,7 @@ describe('Collection result types are simplified', () => { }); test('chained include() produces a plain object', () => { - const users = new Collection({ runtime, context }, 'User'); + const users = new Collection({ runtime, context }, 'User', { namespaceId: 'public' }); const withPostsAndInviter = users.include('posts').include('invitedBy'); type Row = Awaited>; expectTypeOf>().toEqualTypeOf<{ @@ -126,7 +126,7 @@ describe('Collection result types are simplified', () => { }); test('include() with count refinement', () => { - const users = new Collection({ runtime, context }, 'User'); + const users = new Collection({ runtime, context }, 'User', { namespaceId: 'public' }); const withPostCount = users.include('posts', (posts) => posts.count()); type Row = Awaited>; expectTypeOf['posts']>().toEqualTypeOf(); diff --git a/packages/3-extensions/sql-orm-client/test/storage-resolution.test.ts b/packages/3-extensions/sql-orm-client/test/storage-resolution.test.ts new file mode 100644 index 0000000000..1e67df7781 --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/storage-resolution.test.ts @@ -0,0 +1,97 @@ +import type { Contract } from '@prisma-next/contract/types'; +import { + buildSqlNamespace, + SqlStorage, + type SqlStorage as SqlStorageType, + StorageTable, +} from '@prisma-next/sql-contract/types'; +import { blindCast } from '@prisma-next/utils/casts'; +import { describe, expect, it } from 'vitest'; +import { + requireStorageTableForContract, + storageTableForContract, + tableSourceForContract, +} from '../src/storage-resolution'; + +const STORAGE_HASH = blindCast( + 'sha256:test', +); + +function usersTable(columnName: string): StorageTable { + return new StorageTable({ + columns: { + id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false }, + [columnName]: { codecId: 'pg/text@1', nativeType: 'text', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }); +} + +function contractWith(storage: SqlStorageType): Contract { + return blindCast< + Contract, + 'minimal contract wrapping storage for resolver tests' + >({ storage }); +} + +function twoNamespaceSameTableName(): Contract { + return contractWith( + new SqlStorage({ + storageHash: STORAGE_HASH, + namespaces: { + public: buildSqlNamespace({ + id: 'public', + entries: { table: { users: usersTable('email_addr') } }, + }), + auth: buildSqlNamespace({ + id: 'auth', + entries: { table: { users: usersTable('token_col') } }, + }), + }, + }), + ); +} + +describe('storage-resolution coordinate-aware lookups', () => { + it('enumerates columns strictly within the given namespace', () => { + const contract = twoNamespaceSameTableName(); + + expect(Object.keys(storageTableForContract(contract, 'public', 'users').columns)).toEqual([ + 'id', + 'email_addr', + ]); + expect(Object.keys(storageTableForContract(contract, 'auth', 'users').columns)).toEqual([ + 'id', + 'token_col', + ]); + }); + + it('resolves the namespace coordinate strictly', () => { + const contract = twoNamespaceSameTableName(); + + expect(requireStorageTableForContract(contract, 'auth', 'users').namespaceId).toBe('auth'); + expect(tableSourceForContract(contract, 'public', 'users').namespaceId).toBe('public'); + }); + + it('resolves a bare table name within the given namespace', () => { + const contract = contractWith( + new SqlStorage({ + storageHash: STORAGE_HASH, + namespaces: { + public: buildSqlNamespace({ + id: 'public', + entries: { table: { users: usersTable('email_addr') } }, + }), + }, + }), + ); + + expect(Object.keys(storageTableForContract(contract, 'public', 'users').columns)).toEqual([ + 'id', + 'email_addr', + ]); + }); +}); diff --git a/packages/3-extensions/sql-orm-client/test/where-binding-nested-namespace.test.ts b/packages/3-extensions/sql-orm-client/test/where-binding-nested-namespace.test.ts new file mode 100644 index 0000000000..94ced8552c --- /dev/null +++ b/packages/3-extensions/sql-orm-client/test/where-binding-nested-namespace.test.ts @@ -0,0 +1,103 @@ +import type { Contract } from '@prisma-next/contract/types'; +import type { SqlStorage } from '@prisma-next/sql-contract/types'; +import { + BinaryExpr, + ColumnRef, + ExistsExpr, + JoinAst, + LiteralExpr, + type ParamRef, + ProjectionItem, + SelectAst, + TableSource, +} from '@prisma-next/sql-relational-core/ast'; +import { blindCast } from '@prisma-next/utils/casts'; +import { describe, expect, it } from 'vitest'; +import { bindWhereExpr } from '../src/where-binding'; + +function storageTable(columnCodecs: Record) { + const cols: Record = {}; + for (const [column, codecId] of Object.entries(columnCodecs)) { + cols[column] = { codecId, nativeType: codecId, nullable: false }; + } + return { + columns: cols, + primaryKey: { columns: ['id'] }, + uniques: [], + indexes: [], + foreignKeys: [], + }; +} + +// The SAME bare table name `users` exists in both namespaces, each with a +// `token` column carrying a DIFFERENT codec. `public` is deliberately FIRST in +// `Object.keys(storage.namespaces)` so the first-match namespace scan in +// `createParamRef` resolves to `public` (the WRONG namespace) whenever the +// namespace coordinate is dropped on the recursive descent. +const twoNamespaceContract = blindCast, 'hand-built multi-namespace fixture'>({ + target: 'postgres', + targetFamily: 'sql', + capabilities: { returning: { enabled: true } }, + domain: { + namespaces: { + public: { models: {} }, + auth: { models: {} }, + }, + }, + storage: { + storageHash: 'stub', + namespaces: { + public: { + id: 'public', + entries: { table: { users: storageTable({ id: 'pg/int4@1', token: 'pg/text@1' }) } }, + }, + auth: { + id: 'auth', + entries: { table: { users: storageTable({ id: 'pg/int4@1', token: 'pg/varchar@1' }) } }, + }, + }, + }, +}); + +describe('bindWhereExpr nested-subquery namespace coordinate', () => { + it('stamps the per-namespace codec on a param bound inside an EXISTS subquery targeting the second namespace', () => { + // EXISTS subquery whose `from` is the SECOND namespace's `users` and whose + // `where` carries a string literal on `users.token`. The literal must be + // stamped with `auth.users.token` (pg/varchar@1), not the first-match + // `public.users.token` (pg/text@1). + const subquery = SelectAst.from(TableSource.named('users', undefined, 'auth')) + .withProjection([ProjectionItem.of('token', ColumnRef.of('users', 'token'))]) + .withWhere(BinaryExpr.eq(ColumnRef.of('users', 'token'), LiteralExpr.of('secret'))); + + const bound = bindWhereExpr(twoNamespaceContract, ExistsExpr.exists(subquery)) as ExistsExpr; + + const innerWhere = (bound.subquery as SelectAst).where as BinaryExpr; + const param = innerWhere.right as ParamRef; + expect(param.kind).toBe('param-ref'); + expect(param.value).toBe('secret'); + expect(param.codec).toEqual({ codecId: 'pg/varchar@1' }); + }); + + it('stamps the per-namespace codec on a param bound inside a JOIN ON targeting the second namespace', () => { + // A JOIN whose `source` is the SECOND namespace's `users` and whose `on` + // filter carries a string literal on `users.token`. The literal must be + // stamped with `auth.users.token` (pg/varchar@1), not the first-match + // `public.users.token` (pg/text@1). + const join = JoinAst.inner( + TableSource.named('users', undefined, 'auth'), + BinaryExpr.eq(ColumnRef.of('users', 'token'), LiteralExpr.of('secret')), + ); + const query = SelectAst.from(TableSource.named('accounts', undefined, 'public')) + .withProjection([ProjectionItem.of('token', ColumnRef.of('users', 'token'))]) + .withJoins([join]); + + const bound = bindWhereExpr(twoNamespaceContract, ExistsExpr.exists(query)) as ExistsExpr; + + const boundJoin = (bound.subquery as SelectAst).joins?.[0] as JoinAst; + const onExpr = boundJoin.on as BinaryExpr; + const param = onExpr.right as ParamRef; + expect(param.kind).toBe('param-ref'); + expect(param.value).toBe('secret'); + expect(param.codec).toEqual({ codecId: 'pg/varchar@1' }); + }); +}); diff --git a/packages/3-extensions/sqlite/test/fixtures/namespaced-contract.ts b/packages/3-extensions/sqlite/test/fixtures/namespaced-contract.ts new file mode 100644 index 0000000000..61cb15354e --- /dev/null +++ b/packages/3-extensions/sqlite/test/fixtures/namespaced-contract.ts @@ -0,0 +1,77 @@ +import type { Contract as ContractType, StorageHashBase } from '@prisma-next/contract/types'; +import type { ContractWithTypeMaps, TypeMaps } from '@prisma-next/sql-contract/types'; + +// A hand-authored stand-in for an emitted `contract.d.ts`, trimmed to the +// shape the facade reachability tests need: a single `main` namespace whose +// storage carries a `users` table and whose domain carries a `User` model. +// Mirrors the structural literal an emitted contract produces (so it stays +// assignable to the facade's `Contract` bound) without depending +// on a target's generated codec type maps. + +type Models = { + readonly User: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'sqlite/integer@1' }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'sqlite/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'users'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly name: { readonly column: 'name' }; + }; + }; + }; +}; + +type Storage = { + readonly storageHash: StorageHashBase<'sha256:namespaced-facade-fixture'>; + readonly namespaces: { + readonly main: { + readonly id: 'main'; + readonly kind: 'sql-namespace'; + readonly entries: { + readonly table: { + readonly users: { + columns: { + readonly id: { + readonly nativeType: 'integer'; + readonly codecId: 'sqlite/integer@1'; + readonly nullable: false; + }; + readonly name: { + readonly nativeType: 'text'; + readonly codecId: 'sqlite/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; +}; + +type ContractBase = Omit, 'roots' | 'domain'> & { + readonly target: 'sqlite'; + readonly targetFamily: 'sql'; + readonly roots: Record; + readonly domain: { + readonly namespaces: { + readonly main: { readonly models: Models }; + }; + }; +}; + +export type Contract = ContractWithTypeMaps; diff --git a/packages/3-extensions/sqlite/test/namespaced-facade.types.test-d.ts b/packages/3-extensions/sqlite/test/namespaced-facade.types.test-d.ts new file mode 100644 index 0000000000..127ce8d0cb --- /dev/null +++ b/packages/3-extensions/sqlite/test/namespaced-facade.types.test-d.ts @@ -0,0 +1,33 @@ +import type { Db, Namespace, TableProxy } from '@prisma-next/sql-builder/types'; +import { expectTypeOf, test } from 'vitest'; +import type { SqliteClient } from '../src/runtime/sqlite'; +import type { Contract } from './fixtures/namespaced-contract'; + +declare const db: SqliteClient; + +test('db.sql exposes the namespace facet alongside the flat surface', () => { + expectTypeOf(db.sql.main.users).toEqualTypeOf>(); + expectTypeOf(db.sql.users).toEqualTypeOf>(); + expectTypeOf['users']>().toEqualTypeOf< + TableProxy + >(); +}); + +test('db.orm exposes the namespace facet alongside the flat surface', () => { + expectTypeOf(db.orm.main.User).toEqualTypeOf(db.orm.User); + expectTypeOf(db.orm.User).toEqualTypeOf(db.orm.main.User); +}); + +test('an undeclared namespace id is not a key on db.sql or db.orm', () => { + // @ts-expect-error 'auth' is not a declared storage namespace of this contract + db.sql.auth; + // @ts-expect-error 'auth' is not a declared domain namespace of this contract + db.orm.auth; +}); + +test('prepare callback receives the namespaced sql surface', () => { + type PrepareSql = Parameters['prepare']>[1]>[0]; + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); +}); diff --git a/packages/3-extensions/sqlite/test/transaction.test.ts b/packages/3-extensions/sqlite/test/transaction.test.ts index 49d1dc9b02..2a50a7163b 100644 --- a/packages/3-extensions/sqlite/test/transaction.test.ts +++ b/packages/3-extensions/sqlite/test/transaction.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ createSqlExecutionStack: vi.fn(), withTransaction: vi.fn(), sqlBuilder: vi.fn(), + orm: vi.fn(), driverCreate: vi.fn(), driverConnect: vi.fn(), driverClose: vi.fn(), @@ -31,7 +32,7 @@ vi.mock('@prisma-next/sql-builder/runtime', () => ({ })); vi.mock('@prisma-next/sql-orm-client', () => ({ - orm: vi.fn(() => ({ lane: 'orm' })), + orm: mocks.orm, })); vi.mock('@prisma-next/family-sql/ir', () => ({ @@ -70,6 +71,7 @@ describe('sqlite transaction()', () => { mocks.driverClose.mockReset(); mocks.deserializeContract.mockReset(); mocks.sqlBuilder.mockReset(); + mocks.orm.mockReset(); mocks.createExecutionContext.mockReturnValue({ contract, @@ -98,6 +100,7 @@ describe('sqlite transaction()', () => { mocks.createRuntime.mockReturnValue({ id: 'runtime-instance' }); mocks.deserializeContract.mockReturnValue(contract); mocks.sqlBuilder.mockReturnValue({ lane: 'sql' }); + mocks.orm.mockReturnValue({ lane: 'orm' }); mocks.withTransaction.mockImplementation( async (_runtime: unknown, fn: (ctx: unknown) => unknown) => { const mockTxCtx = { @@ -150,14 +153,13 @@ describe('sqlite transaction()', () => { }); it('transaction() provides orm on the transaction context', async () => { - const { orm: ormMock } = await import('@prisma-next/sql-orm-client'); const txOrmProxy = { lane: 'tx-orm' }; let ormCallCount = 0; - vi.mocked(ormMock).mockImplementation((() => { + mocks.orm.mockImplementation(() => { ormCallCount++; if (ormCallCount === 1) return { lane: 'orm' }; return txOrmProxy; - }) as typeof ormMock); + }); const db = sqlite({ contract, diff --git a/projects/explicit-namespace-dsl/learnings.md b/projects/explicit-namespace-dsl/learnings.md new file mode 100644 index 0000000000..05fd5cf883 --- /dev/null +++ b/projects/explicit-namespace-dsl/learnings.md @@ -0,0 +1,29 @@ +# Learnings — explicit-namespace-dsl + +Working ledger (orchestrator-maintained). Cross-cutting lessons migrate to durable docs at close-out; project-local ones drop with the folder. + +## Patterns surfaced this run + +### ORM query-execution path was not covered by the prerequisite's qualification machinery + +**Surfaced:** slice 01, D2 (both implementer and reviewer flagged independently). + +The project spec assumed the explicit namespaced accessors would be queryable end-to-end by reusing TML-2605's runtime-qualification machinery ("no parallel qualification pipeline"). That holds for the **SQL builder** path (`sql..
` → `TableProxyImpl(namespaceId)` → qualified emission). It does **not** hold for the **ORM** path: `collection-contract.ts`'s `modelsOf()` resolves model metadata via `domainModelsAtDefaultNamespace()`, which *throws* on any multi-namespace contract (`soleDomainNamespaceId`). So `orm..` accessor resolution works (table coordinate threaded via `Collection`'s `options.tableName`), but end-to-end query *execution* on a multi-namespace contract is blocked until the collection metadata-resolution path is made namespace-aware. + +**Implication:** an ORM-execution-namespace-awareness substrate change (to `collection-contract.ts` + the metadata path) is required to deliver AC6's ORM half / AC2's runtime half — work the spec did not scope. Routed to the operator as a shape decision (fold into slice 01 vs a separate slice). TML-2605's "consume the machinery, no parallel pipeline" framing was accurate only for the SQL emission path. + +### The ORM single-namespace assumption is threaded layer-by-layer — each dispatch surfaced the next + +**Surfaced:** slice 01, D3→D4→D5→D6 (each dispatch's report flagged the next layer). + +Making the ORM execution path namespace-aware was estimated as ~1 dispatch when the operator chose to fold it into slice 01 (decision (a)). It became **four**: D3 metadata-resolution core → D4 select + count CRUD → D5 returning-row mutations → D6 cross-namespace relation resolution. Each dispatch threaded one bounded layer of the `domainModelsAtDefaultNamespace`-throws assumption and surfaced the next (select → returning → models-with-relations → cross-namespace relation targets). The operator twice chose (a) (fold the next layer in) over carving a separate slice, accepting a heavy 9-dispatch PR1, because D3–D5 were already committed in slice 01 and splitting would un-bundle them. **Lesson for future namespace/coordinate-threading retrofits:** when a pervasive single-X assumption is being made X-aware, size it as a multi-dispatch sub-effort up front (metadata → read execution → write execution → relations), not one dispatch — the layers are discoverable by reading the resolver call-graph before the first dispatch. + +**Decision (a) ×2:** ORM execution-awareness folded into slice 01 (D3–D5); cross-namespace relations folded into slice 01 (D6). Cross-namespace nested-relation *writes* remain a candidate follow-up. The cross-namespace join itself (AC6) is also provable via the SQL builder independent of all the ORM relation work. + +### Same-bare-table-name e2e: full-pipeline gap, and the "bare = default namespace" design + +**Surfaced:** D10 (blocked) + operator review. The same-bare-TABLE-name case (AC1's hard case) is blocked not at the query layer (D7/D8 fixed that) but in the **pipeline around it**: authoring dup-table guard (`build-contract.ts`), PSL bare-model-name keying, contract validation (TML-2807 on main made this namespace-aware), and the execution-context codec registry (`codecRefForColumn(table,column)` — no coordinate). D1–D8's unit tests passed for 8 dispatches *because they bypassed the pipeline* (hand-built contracts). Rebasing onto `origin/main` (TML-2807: `SqlModelStorage.namespaceId` + kind-agnostic storage hash) was clean and cleared the validation layer, but left authoring + codec-registry. + +**Design decision (operator):** bare/flat references resolve to the **connector's `defaultNamespaceId`** (already on the target descriptor), NOT via scan-and-fail-fast or deferral to `storage.namespaceId`. This is the spec's own model (FR6 always-qualified builder; AC4/AC5 facade aliases `db` to the default-namespace facet). It collapses two resolution paths into one (coordinate; bare = default coordinate), retires D7's scan/fail-fast bare branch, and unifies the flat surface with slice 02's facade projection. It does NOT remove the need to (a) drop the authoring dup-guard so two same-bare-named tables are *representable*, or (b) coordinate-key the execution-context codec registry for non-default-namespace tables. + +**Implementation chain (on the rebased base):** P1 authoring (allow same-bare-table-name contracts) → P2 execution pipeline (coordinate-key registry/context + bare=default) → P3 e2e PGlite proof. Lesson: a headline AC like "works when names collide" is a *full-pipeline* property (author → validate → load → query); unit tests that bypass the pipeline give false confidence — exercise the real author→emit→load→query path early. diff --git a/projects/explicit-namespace-dsl/plan.md b/projects/explicit-namespace-dsl/plan.md deleted file mode 100644 index 9e80dba26b..0000000000 --- a/projects/explicit-namespace-dsl/plan.md +++ /dev/null @@ -1,69 +0,0 @@ -# Project Plan - -## Summary - -One PR (~2–3 days) delivering the explicit namespace-aware DSL/ORM surface on top of [TML-2605](https://linear.app/prisma-company/issue/TML-2605). Work is decomposed into four dispatches: resolve collision behaviour and lock type shape; build SQL + ORM namespace accessor types; wire runtime resolution through the qualification path; land Supabase-shaped example coverage and close-out (ADR / upgrade instructions if needed). - -**Spec:** [`projects/explicit-namespace-dsl/spec.md`](spec.md) -**Linear:** [TML-2550](https://linear.app/prisma-company/issue/TML-2550) - -## Cross-project dependencies - -| Direction | Project | Notes | -|---|---|---| -| **Blocked by** | [runtime-qualification](../target-extensible-ir-namespaces/spec.md) ([TML-2605](https://linear.app/prisma-company/issue/TML-2605)) | Identifier qualification + default-namespace fallback must exist before explicit `db.sql.` routing. | -| **Blocks** | [extension-supabase](../extension-supabase/spec.md) ([TML-2503](https://linear.app/prisma-company/issue/TML-2503)) | Launch blocker — colliding `auth.users` / `public.users` require explicit accessors. | -| **Independent of** | [runtime-target-layer](../runtime-target-layer/spec.md), [postgres-rls](../postgres-rls/spec.md), [cross-contract-refs](../cross-contract-refs/spec.md) | No shared code path unless pickup discovers accessor coupling. | -| **Does not gate** | [target-extensible-ir-namespaces](../target-extensible-ir-namespaces/spec.md) close-out | Elevated out of that umbrella intentionally. | - -## Dispatches - -Single PR; dispatches are logical execution order for one implementer (or one reviewable commit series squashed at merge). - -### D1 — Collision decision + type-level accessor shape - -- **Outcome:** Pick option A, B, or C for flat-by-name collision behaviour; `Db` (and related types) expose `db.sql.` and `db.` with namespace keys derived from `contract.storage.namespaces` / `contract.domain.namespaces`. Negative type tests for unknown namespace ids. -- **Builds on:** TML-2605 merged (default-namespace fallback types stable). -- **Hands to:** D2 (runtime can assume frozen accessor shape). -- **Focus:** Spec [Open Questions](../explicit-namespace-dsl/spec.md#open-questions) resolution; type-level construction only — no execute-path changes yet. Draft ADR section if the decision is non-obvious. - -### D2 — Runtime resolution through qualification path - -- **Outcome:** Execute path for `db.sql..
` and `db..` resolves storage/domain coordinates and delegates to TML-2605 qualification helpers; mis-resolution fails fast with actionable diagnostics (FR8–FR9). -- **Builds on:** D1 accessor types. -- **Hands to:** D3 (end-to-end queryable). -- **Focus:** Runtime wiring in DSL/ORM client packages — no second qualification pipeline. Regression tests proving flat paths unchanged (FR6 / AC3). - -### D3 — Multi-namespace example + integration tests - -- **Outcome:** Supabase-shaped fixture: `namespace public { model Profile … }` plus extension `auth` `users`; emit contract; integration test queries `db.sql.auth.users` and `db.sql.public.profile` (and ORM equivalents) against PGlite; AC1–AC4 satisfied. -- **Builds on:** D2 runtime resolution. -- **Hands to:** D4 close-out. -- **Focus:** Authoring + emit + query in one test. **Wire this into the `examples/supabase` walking skeleton** (decisions [C13/C14](../supabase-integration/decisions.md)) rather than a throwaway fixture — add the `auth.users`-alongside-`public.users` explicit-accessor query to the running example, tested via PGlite + `bootstrapSupabaseShim`. `extension-supabase` finalizes the polished demo on top. - -### D4 — Close-out (ADR, upgrade instructions, umbrella tracker) - -- **Outcome:** Collision + surface-shape ADR promoted if warranted; `record-upgrade-instructions` only if a breaking type/export surfaced (working assumption: skip); umbrella README row for explicit-namespace-dsl marked implementer-ready/shipped when PR merges. -- **Builds on:** D3 green CI. -- **Hands to:** [extension-supabase](../extension-supabase/spec.md) unblocked for explicit-namespace query paths in M3 example work. -- **Focus:** Documentation and tracker hygiene — no new features. - -## Definition of done - -- [ ] All [Acceptance Criteria](../explicit-namespace-dsl/spec.md#acceptance-criteria) met (AC1–AC6). -- [ ] Collision-behaviour decision recorded (ADR or spec amendment). -- [ ] `pnpm test:packages` + relevant integration tests green; `pnpm lint:deps` passes. -- [ ] No regressions on default-namespace demo queries (AC3). -- [ ] The `examples/supabase` walking skeleton exercises the explicit `auth.users` / `public.users` accessor (cross-cutting walking-skeleton DoD; [README](../supabase-integration/README.md) §"Walking skeleton"). -- [ ] PR linked from [TML-2550](https://linear.app/prisma-company/issue/TML-2550); operator notified that [TML-2503](https://linear.app/prisma-company/issue/TML-2503) explicit-accessor prerequisite is cleared. - -## Risks and mitigations - -- **Risk:** TML-2605 API drift during parallel development delays pickup. - - **Mitigation:** Do not start D2 until TML-2605 is on `main`; D1 type work can prototype against merged qualification types only. -- **Risk:** Option A (union types) balloons `Db` inference past practical tsc limits. - - **Mitigation:** Decide at D1; fall back to B or C per NFR2 rather than shipping unusable inference. -- **Risk:** Scope creep into emitter per-namespace `contract.d.ts` splits. - - **Mitigation:** Spec non-goals — halt and report if emitter changes become necessary; do not expand PR. -- **Risk:** Role-bound Supabase `Db` wrapper does not forward new namespace facets. - - **Mitigation:** Add a compile test in D3 that `RoleBoundDb` (or the extension-supabase wrapper once available) exposes the same `sql.` / `.` shape as base `Db`. diff --git a/projects/explicit-namespace-dsl/plans/plan.md b/projects/explicit-namespace-dsl/plans/plan.md new file mode 100644 index 0000000000..30ea57d47c --- /dev/null +++ b/projects/explicit-namespace-dsl/plans/plan.md @@ -0,0 +1,106 @@ +# Explicit namespace-aware DSL/ORM + +## Summary + +Reshape the ORM/SQL builder surface so namespace selection is always explicit (`orm..`, `sql..
`), removing the flat default-namespace fallback at the builder layer. Project the unified `db` facade per target descriptor: qualified shape for multi-namespace targets (Postgres), unbound-aliased flat shape for single-namespace targets (SQLite, Mongo). Success = `db` exposes the right shape on each of the three concrete targets in the repo, a multi-namespace fixture is queryable end-to-end, and the design is captured in a long-lived ADR. + +**Spec:** [`../spec.md`](../spec.md) + +**Linear:** [TML-2816](https://linear.app/prisma-company/issue/TML-2816/always-qualified-namespace-aware-dslorm-surface-ormnsmodel-sqlnstable) + +## Collaborators + +| Role | Person/Team | Context | +| --- | --- | --- | +| Maker | Serhii Tatarintsev | Assignee on TML-2816 | +| Hard prerequisite | TML-2605 author | Runtime identifier-qualification machinery this project consumes; must be merged before M1 begins | +| Reviewer | Terminal team (PR review) | Architectural review of builder/facade surface; ADR review | +| Collaborator | _TBC_ | Anyone owning downstream consumers that bypass the facade and use `orm` / `sql` directly on multi-namespace contracts | + +## Shipping Strategy + +The change is a deliberate breaking reshape of the **builder layer** (`orm`, `sql`): flat default-namespace accessors are removed; namespace selection becomes mandatory. The original plan called for a single PR on the reasoning that a *simultaneous* add-and-remove leaves main broken between merges. That reasoning only binds the simultaneous shape — an **additive-then-cut** sequencing splits the work into two slices that each merge to main green, with the breaking removal isolated to the second: + +| Slice | PR | Merges to main | Delivers | +|---|---|---|---| +| [`slices/01-additive-namespaced-surface`](../slices/01-additive-namespaced-surface/spec.md) | PR1 | green (flat surface retained alongside namespaced) | AC1, AC2, AC6 — the namespaced surface added end-to-end and proven on PGlite | +| [`slices/02-remove-flat-fallback`](../slices/02-remove-flat-fallback/spec.md) | PR2 | green (breaking, coherent) | AC3, AC4, AC5, AC7 — flat removal + `defaultNamespaceId`-keyed facade projection + ADR | + +The transient cost is a short-lived dual-shape window on the builder layer between PR1 and PR2 (both flat and namespaced present). The spec lists the flat fallback as a non-goal of the *end state*, so the additive transitional shape is consistent with intent. **F1 watch** (`drive/calibration/failure-modes.md`): the PR2 removal must delete the flat fallback outright, not relocate it under a new name. + +> **AC placement nuance.** AC4 ("flat `db.
` absent on postgres") and AC5 (exclusive projection) assert the *absence* of the flat shape, so they can only fully verify in PR2 after the builder-layer flat surface is removed — even though the namespaced surface is reachable through the facade already in PR1. + +End-user impact is bounded by the facade: + +- **Single-namespace targets** (SQLite, Mongo): the facade aliases `db = orm.__unbound__` (and analogously for `db.sql`). User call sites (`db.`, `db.sql.
`) keep working without edits. +- **Multi-namespace targets** (Postgres): the facade exposes the qualified shape directly. User call sites must qualify (`db..`, `db.sql..
`). This is the deliberate breaking change. + +Implicit gate: the facade's `defaultNamespaceId === UNBOUND_NAMESPACE_ID` discriminator. There is no feature flag; the breaking change is atomic at merge time. Upgrade instructions are recorded alongside the breaking-change PR per the `record-upgrade-instructions` skill, so downstream Postgres consumers can mechanically qualify their call sites. + +Hard prerequisite: [TML-2605](https://linear.app/prisma-company/issue/TML-2605) (runtime-qualification) must be merged before M1 begins. This project reuses its identifier-qualification helpers; it does not re-implement them. + +Release reaches users on the next NPM release-train cut. The wait between merge and publication is a task inside M2, not a separate Deploy milestone. + +## Test Design + +| AC | TC | Test Case | Type | Milestone | Expected Outcome | +| --- | --- | --- | --- | --- | --- | +| AC-1 | TC-1 | Multi-namespace contract where `auth.users` and `public.users` both exist: `sql.public.users` and `sql.auth.users` resolve and execute against distinct tables | Integration (PGlite) | M1 | Both queries succeed; emitted SQL contains `"public"."users"` vs `"auth"."users"` | +| AC-1 | TC-2 | Explicit SQL accessor supports the same builder operations (select / insert / update / delete) as the previously flat path | Integration | M1 | All four operation kinds execute through the qualified accessor and return expected results | +| AC-2 | TC-3 | `orm.public.User.find` and `orm.auth.User.find` resolve to the correct namespace's model accessor | Integration | M1 | Find / create / update / delete on both namespaces return correct rows | +| AC-2 | TC-4 | ORM namespace keys equal SQL namespace keys for the same contract | Type-level | M1 | `keyof typeof orm === keyof typeof sql` on a multi-namespace fixture | +| AC-4 | TC-7 | Postgres facade: `db.public.User.find` executes against `"public"."users"` | Integration | M1 | Qualified path returns expected rows | +| AC-4 | TC-8 | Postgres facade: `db.sql.public.users.select` executes against `"public"."users"` | Integration | M1 | Qualified SQL builder path returns expected rows | +| AC-4 | TC-9 | SQLite facade: `db.User.find` works (aliased to `orm.__unbound__.User`) | Integration | M1 | Existing SQLite test fixtures pass without call-site changes | +| AC-4 | TC-10 | SQLite facade: `db.sql.users` works (aliased to `sql.__unbound__.users`) | Integration | M1 | Existing SQLite SQL-DSL fixtures pass without call-site changes | +| AC-4 | TC-11 | Mongo facade: `db.` works (aliased to `orm.__unbound__`) | Integration | M1 | Existing Mongo test fixtures pass without call-site changes | +| AC-5 | TC-12 | Facade projection helper has no per-target switch | Static (grep + code review) | M1 | No `switch (targetId)` / `if (familyId === ...)` in facade construction; single shared helper dispatches purely on `defaultNamespaceId` | +| AC-5 | TC-13 | Type-level: a target pack with `defaultNamespaceId: 'public'` yields a qualified `Db`; a pack with `'__unbound__'` yields a flat `Db` | Type-level | M1 | Asserted against Postgres pack, SQLite pack, Mongo pack | +| AC-6 | TC-14 | Multi-namespace PSL fixture (two namespaces + cross-namespace FK) parses; emits `contract.json` with both `domain.namespaces` and `storage.namespaces` keyed by namespace id; round-trips through `validateContract` | Integration | M1 | Contract IR matches expected shape; FK references the correct cross-namespace coordinate | +| AC-6 | TC-15 | Same fixture queryable end-to-end via PGlite using explicit accessors, including the FK-mediated relation | Integration (PGlite) | M1 | Cross-namespace join returns expected rows | +| AC-7 | TC-16 | ADR exists under `docs/architecture docs/adrs/` covering (a) always-qualified builder surface, (b) facade-aliasing pattern, (c) `Db` per-namespace facet construction | Doc review | M2 (drafted in M1; migrated in M2) | +| AC-8 | TC-17 | `pnpm test:packages` green on the merge candidate | Harness | M1 | +| AC-8 | TC-18 | `pnpm lint:deps` green on the merge candidate | Harness | M1 | +| FR-7 | TC-19 | `contract.json` shape unchanged for an existing single-namespace fixture (snapshot regression) | Integration / snapshot | M1 | Snapshot diff is empty for `packages/**/test/__snapshots__/contract.json.*` representing pre-change fixtures | +| FR-10 | TC-20 | Explicit accessors invoke the TML-2605 identifier-qualification helper rather than a parallel pipeline | Static (code review + grep) | M1 | Per-namespace facet construction imports the same qualification helper as TML-2605's emit path; no duplicate qualifier implementation in this project's diff | +| FR-11 | TC-21 | Unknown namespace id passed at runtime (contract widened from JSON) fails fast with a diagnostic naming the namespace | Integration | M1 | Thrown error mentions the offending namespace id | + +## Milestones + +### Implement M1: Always-qualified builders and per-target facade projection + +_Outcomes_ +The framework's `orm` and `sql` builder surfaces expose per-namespace facets only — flat default-namespace accessors are gone. The unified `db` facade projects per target descriptor: Postgres requires qualified call sites, SQLite and Mongo preserve the flat `db.` / `db.sql.
` shape via `db = orm.__unbound__` aliasing. A multi-namespace fixture is queryable end-to-end on Postgres/PGlite; single-namespace integration tests pass on SQLite and Mongo without call-site edits. ADR is drafted (still inside `projects/explicit-namespace-dsl/`). `pnpm test:packages` and `pnpm lint:deps` are green on the merge candidate. + +**Tasks:** + +- [ ] Move TML-2816 to In Progress +- [ ] **Builder reshape (rename-and-map).** The existing `Db` shape (currently a flat map of model/table accessors) becomes the **content of a single namespace** — rename to `Namespace` (or equivalent). The new `Db` is a mapped type over `contract..namespaces`: `{ [Ns in keyof contract.namespaces]: Namespace }`. Apply the same rename-and-map pattern symmetrically to the ORM and SQL builder types. Result: per-namespace facets fall out of the construction; the flat shape is structurally impossible to reintroduce. (satisfies: TC-4) +- [ ] **Runtime resolution wiring.** Per-namespace accessors delegate to the TML-2605 identifier-qualification helper, parameterized by namespace coordinate — no parallel qualifier. Fail-fast diagnostic when a runtime-widened contract carries an unknown namespace / table / model name; message names the offending namespace. (satisfies: TC-20, TC-21) +- [ ] **Facade projection.** Single shared helper keyed on `defaultNamespaceId === UNBOUND_NAMESPACE_ID`: when unbound, alias `db = orm.__unbound__` and `db.sql = sql.__unbound__`; otherwise expose `db = orm` and `db.sql = sql` directly. Wire into the Postgres facade (`packages/3-targets/3-targets/postgres`), SQLite facade (`packages/3-targets/3-targets/sqlite`), and Mongo facade (`packages/3-mongo-target/1-mongo-target`) using the same helper — no per-target switch. (satisfies: TC-12, TC-13) +- [ ] **Integration test pass — multi-namespace path on Postgres.** Author a multi-namespace PSL fixture (`public.Profile` + `auth.User` + cross-namespace FK). Emit `contract.json`; assert IR shape; query via `sql.public.profile`, `sql.auth.users`, `orm.public.Profile`, `orm.auth.User` against PGlite; exercise the FK relation. Cover select / insert / update / delete via the qualified accessor. Also exercise the Postgres facade (`db.public.User.find`, `db.sql.public.users.select`). (satisfies: TC-1, TC-2, TC-3, TC-7, TC-8, TC-14, TC-15) +- [ ] **Integration test pass — single-namespace facade-alias path.** SQLite: `db.` and `db.sql.
` resolve through `orm.__unbound__` / `sql.__unbound__` and execute correctly on existing single-namespace fixtures. Mongo: analogous coverage on the Mongo ORM surface. (satisfies: TC-9, TC-10, TC-11) +- [ ] **Regression snapshot.** Confirm `contract.json` shape is byte-identical for an existing single-namespace fixture (no emitter changes leaked into this project). (satisfies: TC-19) +- [ ] **Record upgrade instructions** per the `record-upgrade-instructions` skill for the builder-layer flat-accessor removal. Scope: downstream consumers (extension packs, example apps) that call `orm.` or `sql.
` directly on a multi-namespace contract — they must qualify. Single-namespace consumers using the `db` facade need no changes. +- [ ] **Draft ADR** in `projects/explicit-namespace-dsl/` covering (a) always-qualified builder surface, (b) facade-aliasing pattern keyed on `defaultNamespaceId`, (c) `Db` per-namespace facet construction. Cross-link to TML-2605 and to the per-target facade wiring sites. (drafts TC-16; final-form migration happens in M2) +- [ ] **Validation gate.** `pnpm typecheck`, `pnpm test:packages`, `pnpm lint:deps` green on the merge candidate. (satisfies: TC-17, TC-18) + +### Release M2: Feature in users' hands; project closed out + +_Outcomes_ +The change is published to NPM via the next release-train cut and reaches users on `latest`. The ADR lives in its long-lived home under `docs/architecture docs/adrs/`. All acceptance criteria are verified against the merged PR. `projects/explicit-namespace-dsl/` no longer exists in the repo. + +**Tasks:** + +- [ ] **Migrate ADR** from `projects/explicit-namespace-dsl/` to `docs/architecture docs/adrs/` with the next available ADR number; update any in-code or in-docs cross-references to the project path to point at the new ADR location. (satisfies: TC-16) +- [ ] **Verify all acceptance criteria** are met against the merged PR (AC1–AC8 in `projects/explicit-namespace-dsl/spec.md`); link each to its TC evidence (test files, snapshot, ADR location). +- [ ] **Cut / await the next NPM release train.** Once the framework + target packages publish, confirm the change is present in the published `@prisma-next/*` versions on `latest` (per `publish-npm-version` skill). +- [ ] **Close-out:** delete `projects/explicit-namespace-dsl/`; move TML-2816 to Done. + +## Open Items + +- **ORM query-execution path not multi-namespace-aware (discovered slice 01 D2).** `collection-contract.ts`'s `modelsOf()` → `domainModelsAtDefaultNamespace()` throws on any multi-namespace contract. **Resolved — operator chose (a):** folded into slice 01 as a new additive dispatch (ORM execution namespace-awareness, local to `sql-orm-client`); project stays 2 PRs, PR1 is heavier. See `slices/01-additive-namespaced-surface/{spec,plan}.md` (Dispatch 3). +- **Collaborator / reviewer naming.** Spec does not name specific reviewers; left as TBC until M1 starts. Downstream-consumer collaborator likewise TBC pending upgrade-instructions-scope review. +- **Upgrade-instructions scope.** Working assumption: only downstream consumers that call `orm` / `sql` directly on multi-namespace contracts are affected; facade users (`db.`) are unaffected. Reviewer to confirm at PR time; widen the upgrade instructions if internal extension packs (`packages/3-extensions/*`) surface additional bypass sites. +- **Type-inference cost (NFR2).** If the per-namespace facet pattern strains TypeScript inference on realistic contract sizes, the ADR records the mitigation (e.g. lazy expansion of the namespace map). Re-evaluate during M1 if compile times regress noticeably. +- **TC-20 verification mode.** Listed as code review + grep against the project diff. If a structural test (e.g. an import-graph assertion) is feasible at low cost, prefer that over manual review. diff --git a/projects/explicit-namespace-dsl/slices/01-additive-namespaced-surface/plan.md b/projects/explicit-namespace-dsl/slices/01-additive-namespaced-surface/plan.md new file mode 100644 index 0000000000..399e3c2829 --- /dev/null +++ b/projects/explicit-namespace-dsl/slices/01-additive-namespaced-surface/plan.md @@ -0,0 +1,95 @@ +# Dispatch plan — slice 01: additive namespaced surface + +Spec: [`./spec.md`](./spec.md). Sequence is sequential per the single persistent implementer; D1 and D2 are logically independent (different packages) but run back-to-back. + +### Dispatch 1: SQL builder namespaced facet (additive) + +- **Outcome:** `sql..
` resolves the table in the named storage namespace and produces namespace-qualified SQL; the flat `sql.
` path still resolves unchanged. `Db` is the additive intersection (flat ∩ per-namespace facets). +- **Builds on:** The spec's chosen design; the merged TML-2605 `TableProxyImpl(namespaceId)` + `resolveStorageTable`. +- **Hands to:** `Db` carries `Namespace` facets; `sql()` is a two-level proxy (namespace → table) delegating to the existing qualification path. The type `Namespace` is exported from `sql-builder/types`. +- **Focus:** `packages/2-sql/4-lanes/sql-builder` only — `src/types/db.ts`, `src/runtime/sql.ts`, type-level + unit tests. No flat-surface removal. No facade edits. +- **Tests-first / gates:** type-level test (namespaced keys present; flat keys still present; unknown-`ns` is a type error) written before the impl; unit test that a bare name present in two namespaces resolves to the correct table per namespace. Gate: `pnpm --filter @prisma-next/sql-builder typecheck` (+ test tsconfig) + `pnpm --filter @prisma-next/sql-builder lint` + `pnpm --filter @prisma-next/sql-builder test` + `pnpm lint:deps`. + +### Dispatch 2: ORM client namespaced facet (additive) + +- **Outcome:** `orm..` resolves the model accessor in the named domain namespace; flat `orm.` still resolves unchanged. ORM namespace keys equal SQL storage-namespace keys for the same contract. +- **Builds on:** D1's shape (mirrors it in the ORM package; independent package, no shared write scope). +- **Hands to:** `orm()` exposes per-namespace model-collection facets keyed on `contract.domain.namespaces`, flat map retained. +- **Focus:** `packages/3-extensions/sql-orm-client` — `src/orm.ts`, `src/storage-resolution.ts` (namespace key derivation), tests. No flat removal. +- **Tests-first / gates:** type-level test incl. `keyof (namespaced orm) === keyof (namespaced sql)` on a multi-namespace fixture, written first. Gate: same per-package shape as D1 for `@prisma-next/sql-orm-client`. + +### Dispatch 3: ORM metadata-resolution namespace-awareness (additive) — DONE + +- **Outcome:** the ORM **metadata-resolution** path resolves model metadata *within the collection's namespace* (`collection-contract.ts`'s `modelsOf()` + the five field/column/relation/polymorphism resolvers take an optional trailing `namespaceId`, caches namespace-keyed) instead of throwing via the `domainModelsAtDefaultNamespace()` default. Single-namespace resolution is byte-identical. Discriminating per F13 (same bare model name in two namespaces resolves distinct metadata). +- **Builds on:** D2's `orm..` facet (knows its namespace coordinate; threads scoped table via `options.tableName`). +- **Hands to:** `Collection` carries its `namespaceId`; the metadata resolvers accept it (optional, default = sole-namespace path). Stable state D4 threads the same coordinate through the *execution* layer. +- **Focus:** `sql-orm-client` only — `collection-contract.ts`, `collection.ts`, `collection-internal-types.ts`, `orm.ts`. Foundation untouched. Landed in `8c06a7e2a` (test) + `ca0c29983` (feat). + +### Dispatch 4: ORM execution-runtime namespace threading — select + count CRUD (additive) — DONE + +- **Outcome:** `orm..` **select** and **count-terminal** CRUD (`createCount`/`updateCount`/`deleteCount`) execute on a multi-namespace contract — `collection-runtime.ts`, `collection-column-mapping.ts`, `model-accessor.ts`, `filters.ts`, `collection-dispatch.ts`, `query-plan-select.ts` thread the collection's `namespaceId`. Single-namespace execution byte-identical. +- **Builds on:** D3's namespace-keyed metadata resolvers. +- **Hands to:** select + count CRUD execute per-namespace against a mock runtime. **Returning-row mutations** (`create`/`update`/`delete` returning the row) route through `mutation-executor.ts` — not yet threaded; that's D5. +- **Focus:** `sql-orm-client` execution-runtime files. Foundation untouched. Landed in `81ec6e57c` (test) + `a8f11cc2f` (feat). + +### Dispatch 5: ORM returning-mutation execution threading (additive) + +- **Outcome:** `orm..` **returning-row** mutations (`create` / `createAll` / `update` / `updateAll` / `delete` / `deleteAll`) execute on a multi-namespace contract — `mutation-executor.ts` (`dispatchMutationRows` row-shaping, `buildPrimaryKeyFilterFromRow`, the returning-row mapping path) threads the collection's `namespaceId` for **base-model** writes. Single-namespace execution byte-identical. +- **Builds on:** D4 (select + count CRUD threaded; the `namespaceId` coordinate flows from `Collection` into the dispatch layer). +- **Hands to:** full base-model CRUD (returning + count) executes per-namespace; D7's PGlite proof can exercise returning mutations on the ORM accessor path. +- **Focus:** `sql-orm-client` — `mutation-executor.ts` + any returning-row shaping helper it calls. **Cross-namespace nested-relation writes are out of slice-01 scope** (the related model in another namespace) — halt-and-surface if base-model returning mutations unavoidably require them. Foundation halt-and-surface rule as D3/D4. +- **Tests-first / gates:** test (FIRST) exercising a returning `create` + `update` (or `delete`) via `orm..` on a two-namespace same-bare-name contract (mock runtime acceptable), per-namespace-correct, discriminating (F13). Single-namespace returning-mutation regression green. Gate: per-package typecheck + lint + test for `@prisma-next/sql-orm-client` + `pnpm lint:deps`. + +### Dispatch 6: cross-namespace relation resolution (additive) + +- **Outcome:** ORM operations on a model that **declares a relation** execute on a multi-namespace contract, and a **cross-namespace relation** (`public.Profile.user → auth.User`) resolves and is queryable via the ORM accessor — `getRelationDefinitions`'s relation-*target* resolution (`mutation-executor.ts` ~698/702, `resolveFieldToColumn(relation.to, …)`) and the include-read traversal resolve the target model's metadata within **`relation.to.namespace`** instead of the default/first-match path. Single-namespace behaviour byte-identical. +- **Builds on:** D5 (base-model write threading; the relation-*target* sites D5 deliberately left at default resolution are the surface here). +- **Hands to:** the cross-namespace FK fixture's `Profile`↔`auth.User` relation is queryable end-to-end via ORM — base CRUD on a model-with-relations + a cross-namespace `include` read. D8's PGlite proof can exercise the FK-mediated relation through the ORM path. +- **Focus:** `sql-orm-client` — relation-target resolution in `mutation-executor.ts` / `collection-contract.ts` (`resolveIncludeRelation` target) / the include-read path. Thread `relation.to.namespace` (cross-references already carry their namespace). **Cross-namespace nested-relation *writes*** (a nested `create`/`connect` of a related model in another namespace) may stay out of scope — **halt and surface** if the D8 fixture's read + base-CRUD proof unavoidably needs them, so we size that explicitly rather than ballooning silently. Foundation halt-and-surface rule as D3–D5. +- **Tests-first / gates:** test (FIRST) on a two-namespace contract with a cross-namespace FK relation: ORM base CRUD on the relation-declaring model executes, and a cross-namespace `include` read returns the related row from the other namespace; discriminating (F13). Single-namespace relation behaviour green. Gate: per-package typecheck + lint + test for `@prisma-next/sql-orm-client` + `pnpm lint:deps`. + +### Dispatch 7: coordinate-aware core column/codec resolvers (additive) — DONE + +- **Outcome (corrective):** the core resolvers `resolveStorageTable` (`sql-contract`), `codecRefForStorageColumn` (`relational-core`), and `resolveTableColumns`/`storageTableForContract` (`sql-orm-client`) take an optional trailing `namespaceId`: coordinate ⇒ strict within-namespace resolution; ambiguous bare name without a coordinate ⇒ **fail-fast naming the namespaces** (FR11) instead of silent first-match. Closes the codec-layer gap that distinct-table-name fixtures had masked. Additive; single-namespace byte-identical; no contract-IR/emitter/JSON change. +- **Builds on:** D1–D6 (the coordinate is already present at every call site: `TableSource.namespaceId`, `relation.to.namespace`). +- **Hands to:** D8 threads the coordinate (already in hand) into every column/codec call site so same-bare-TABLE-name resolves correctly. Landed in `3fccb8d2a` (test) + `48a3dc2ad` (feat). + +### Dispatch 8: thread the coordinate through all column/codec call sites + re-prove AC1 (additive) + +- **Outcome:** every SQL-builder + ORM column/codec call site passes the namespace coordinate it already holds into the D7 resolvers — `sql-builder` `builder-base.codecRefFor`/`tableToScope` (from `TableProxyImpl.namespaceId`), ORM `query-plan-select` `buildProjection`, `query-plan-mutations`, `query-plan-aggregate`, `where-binding`, `model-accessor`, and the include child-SELECT projection (from `relatedNamespaceId`). After D8, **same bare TABLE name in two namespaces with differing columns resolves correctly through every path** (`sql..
`, `orm..`, cross-ns include child SELECT). AC1 is delivered for real. +- **Builds on:** D7 (coordinate-aware resolvers). +- **Hands to:** the codec/column layer is namespace-correct end-to-end; D10's PGlite proof can use a genuine same-bare-TABLE-name + differing-columns fixture including the cross-ns include target. +- **Focus:** `sql-builder` (`builder-base.ts`) + `sql-orm-client` call sites listed above. **Also fix the pre-existing D2-era facade-test break** (`@prisma-next/postgres` `postgres.test.ts` mock-cast invalidated by D2's namespaced `OrmClient` index signature; check sqlite/mongo facade tests for the same) — **separate commit, scope-note** — so the workspace typecheck gate is green. +- **Tests-first / gates:** re-prove with a same-bare-TABLE-name + **differing-columns** fixture: `sql.public.users`/`sql.auth.users` and `orm.public.User`/`orm.auth.User` resolve distinct columns/codecs; strengthen the D1–D6 tests that used distinct table names; discriminating (F13). Gate: per-package typecheck + lint + test for `@prisma-next/sql-builder` + `@prisma-next/sql-orm-client`, **`pnpm typecheck` (workspace — mirror CI; F14)**, `pnpm lint:deps`. + +### Dispatch 9: facade reachability (postgres / sqlite / mongo) + +- **Outcome:** The namespaced surface is reachable through each facade's `db` — `db.sql..
` and `db.orm..` resolve, including inside `transaction(...)` and `prepare(...)`. Existing flat call sites through `db` still typecheck and run. +- **Builds on:** D1 + D2 (the builder/ORM types now carry the facets; the facade members re-type to them). Independent of the D3–D8 execution work; all must land before D10's integration proof. +- **Hands to:** All three facades expose both shapes additively; facade type re-exports (`Db`, ORM client type) updated where they pin the shape. +- **Focus:** `packages/3-extensions/postgres`, the sqlite extension, `packages/3-mongo-target/1-mongo-target` (mongo facade) — facade + transaction-context + prepare typings only. No projection helper (that is slice 02). +- **Tests-first / gates:** existing facade type-tests extended to assert the namespaced member shape. Gate: per-package typecheck + lint + test for each touched facade + `pnpm lint:deps`. + +### Dispatch 10: multi-namespace integration proof (Postgres / PGlite) + +- **Outcome:** A two-namespace PSL fixture (`public` + `auth`, **same bare table name in both, with differing columns**, plus a cross-namespace FK) is authorable → emittable (`contract.json` with both `domain.namespaces` and `storage.namespaces` keyed by id; FK carries the cross-namespace coordinate) → queryable end-to-end on PGlite via **both** explicit accessor paths: `sql..
` and `orm..`, covering select / insert / update / delete on both namespaces and the FK-mediated cross-namespace relation (incl. an ORM `include` read across namespaces). +- **Builds on:** D8 (same-bare-TABLE-name resolves correctly through every path) + D9 (accessors reachable through the postgres facade). +- **Hands to:** Project AC1 / AC2 / AC6 covered by a committed integration test; the multi-namespace fixture exists for slice 02 to reuse. +- **Focus:** new integration test + PSL fixture. **Fixture uses the genuine same-bare-TABLE-name + differing-columns shape** (the deferred-core-boundary corner is reversed — D7/D8 make this correct, including the cross-ns include target). The fixture must straddle the boundary (F13): the two same-named tables differ in columns so first-match would fail. +- **Tests-first / gates:** the integration test *is* the deliverable. Gate: the PGlite integration test green + `pnpm fixtures:check`. + +### Dispatch 11: single-namespace regression (SQLite / Mongo) + snapshot + +- **Outcome:** Existing single-namespace fixtures on SQLite and Mongo still resolve flat `db.` / `db.sql.
` unedited (additive change broke nothing); the single-namespace `contract.json` snapshot is byte-identical (project FR7 / TC-19). +- **Builds on:** D9. +- **Hands to:** Confidence that the additive surface is non-breaking; slice 02 inherits a green single-namespace baseline to alias against. +- **Focus:** run/extend existing sqlite + mongo integration fixtures; assert no contract.json snapshot drift. No new call-site edits. +- **Tests-first / gates:** existing fixtures pass unedited; snapshot diff empty. Gate: sqlite + mongo integration tests + `pnpm fixtures:check`. + +## Slice-close gate + +Before PR-open: sync `origin/main`, re-run `pnpm typecheck` + `pnpm test:packages` + `pnpm lint:deps` + `pnpm fixtures:check` (F14 / slice-close ritual). + +## Open items + +- D3 mongo facade lives in `packages/3-mongo-target/1-mongo-target`; confirm the mongo ORM surface mirrors the SQL ORM facet shape (it uses a separate query-builder package, `packages/2-mongo-family/5-query-builders/orm`). The implementer's D3 pre-flight grep confirms the exact mongo touch-points. diff --git a/projects/explicit-namespace-dsl/slices/01-additive-namespaced-surface/spec.md b/projects/explicit-namespace-dsl/slices/01-additive-namespaced-surface/spec.md new file mode 100644 index 0000000000..bdb867202b --- /dev/null +++ b/projects/explicit-namespace-dsl/slices/01-additive-namespaced-surface/spec.md @@ -0,0 +1,86 @@ +# Slice: additive namespaced DSL/ORM surface + +_(Parent project `projects/explicit-namespace-dsl/`. Contributes: the explicit `sql..
` / `orm..` accessors exist and are queryable end-to-end — the new capability, added **without** removing today's flat surface, so this slice merges to main green.)_ + +## At a glance + +Adds per-namespace facets to the SQL builder (`sql..
`) and ORM client (`orm..`), makes the ORM execution path namespace-aware so those accessors **query end-to-end** on a multi-namespace contract, exposes the surface through each target facade's `db`, and proves it end-to-end on a two-namespace PGlite fixture. The existing flat surface (`sql.
` / `orm.`) stays in place; removing it is slice 02's job. This is the additive half of the additive-then-cut split. + +## Chosen design + +### SQL builder — `packages/2-sql/4-lanes/sql-builder` + +`Db` today is a **flat** map keyed by every table name across all namespaces (`src/types/db.ts`), and `sql()` returns a single-level `Proxy` whose `get(prop)` calls `resolveTableForFlatName` (`src/runtime/sql.ts`). The file's own header comment already flags the namespaced surface as the tracked follow-up — this is it. + +**Type (additive intersection):** + +```ts +// new — a namespace facet: the tables of one storage namespace +export type Namespace = { + readonly [Name in keyof C['storage']['namespaces'][NsId]['tables'] & string]: TableProxy; +}; + +// Db gains namespace keys alongside the existing flat keys. +// Flat keys retained in this slice; slice 02 drops them, leaving only the mapped half. +export type Db = + & { readonly [Name in TableNamesAcrossNamespaces]: TableProxy } // existing flat (retained) + & { readonly [Ns in keyof C['storage']['namespaces']]: Namespace }; // new namespaced +``` + +**Runtime (two-level proxy):** `sql()`'s `get(prop)` first checks whether `prop` is a declared storage namespace id (`prop in storage.namespaces`) → return a **namespace facet proxy** whose `get(table)` resolves the table *within that namespace coordinate* and constructs `TableProxyImpl(table, …, ctx, nsId)`. `TableProxyImpl` already accepts the `namespaceId` as its last constructor argument (see `runtime/sql.ts`), so the TML-2605 qualification machinery flows through unchanged — no parallel qualifier. If `prop` is not a namespace id, fall back to the existing flat `resolveTableForFlatName` path. + +### ORM client — `packages/3-extensions/sql-orm-client` + +Mirror the same shape on `orm()` (`src/orm.ts`): add an `orm..` facet keyed on `contract.domain.namespaces`, returning the model collections scoped to that domain namespace; retain the flat `orm.` map. The domain-namespace keys must equal the SQL storage-namespace keys for the same contract (project AC2). + +### ORM execution path — `packages/3-extensions/sql-orm-client` (folded in per operator decision) + +Accessor *resolution* is not enough: the ORM execution core (`collection-contract.ts`) resolves model metadata through `domainModelsAtDefaultNamespace()`, which **throws on any multi-namespace contract**, so an `orm..` query would throw on execution. This slice makes that path namespace-aware: the `Collection` carries its `namespaceId` (the namespace facet knows it) and `modelsOf()` / the metadata resolvers resolve *within that namespace* (`contract.domain.namespaces[nsId].models`, already directly accessible — no contract-foundation change). Additive: single-namespace execution is unchanged; flat bare-name access on a multi-namespace contract may still throw (ambiguous). This was discovered at dispatch time (the prerequisite TML-2605 qualified the SQL emission path, not the ORM metadata path); the operator chose to fold it into this slice rather than split it out. + +### Facade reachability — postgres / sqlite / mongo extensions + +The facades (`packages/3-extensions/postgres/src/runtime/postgres.ts` and the sqlite / mongo equivalents) return the `db` object whose `sql` / `orm` members are typed as the builder types above. Because `Db` / the ORM client type are additive in this slice, the namespaced surface flows through `db.sql..
` / `db.orm..` automatically — plus the transaction-context and `prepare`-callback surfaces that re-type `sql` / `orm`. This slice ensures the namespaced shape is **reachable through the facade**, proven by the integration test. The `defaultNamespaceId`-keyed *exclusive* projection (alias-flat vs qualified-only) is **slice 02**, because it only does work once the flat surface is removed. + +## Coherence rationale + +One reviewable PR = "the namespaced accessor surface exists, **executes** end-to-end, and is proven on a real multi-namespace query." It is the "one new authoring surface end-to-end" slice-shape pattern: builder type-side → ORM type-side → ORM execution-awareness → facade reachability → integration proof. Nothing is removed — every change is additive (single-namespace behaviour is byte-for-byte unchanged), so even with the execution-core substrate change folded in, the slice remains one coherent "make the namespaced accessor real" unit. The operator accepted the heavier PR1 (decision (a)) over splitting the execution-awareness into its own slice. + +## Scope + +**In:** +- `sql..
` facet (types + two-level proxy) in `sql-builder`, flat surface retained. +- `orm..` facet in `sql-orm-client`, flat surface retained. +- ORM execution path made namespace-aware (`collection-contract.ts` metadata resolution scoped by the collection's namespace) so `orm..` queries execute on multi-namespace contracts. Local to `sql-orm-client` — no contract-foundation change (halt-and-surface if one becomes necessary). Delivered across D3 (metadata core) → D4 (select + count CRUD) → D5 (returning mutations) → D6 (cross-namespace relation resolution + include reads) — the ORM execution core's single-namespace assumption is threaded layer by layer. +- **Cross-namespace relations** (`public.Profile.user → auth.User`) resolve and are queryable via the ORM accessor: ORM ops on a model that declares a relation execute on multi-namespace, and a cross-namespace `include` read returns the related row (per operator decision (a)). Cross-namespace nested-relation *writes* may remain a follow-up (D6 halts-and-surfaces if the proof needs them). +- Namespaced surface reachable through the postgres / sqlite / mongo facade `db` (incl. transaction + prepare surfaces). +- A two-namespace PSL fixture + emit + IR assertions + PGlite end-to-end query via **both** explicit accessor paths (`sql..
` and `orm..`). + +**Out:** +- Cross-namespace nested-relation *writes* (nested `create`/`connect` of a related model in another namespace) — follow-up unless D6 finds the read + base-CRUD proof unavoidably needs them. + +**Out (slice 02):** +- Removing the flat builder-layer accessors (the breaking cut). +- The `defaultNamespaceId`-keyed exclusive projection helper (alias for single-namespace, qualified-only for multi-namespace) and its type-level AC4/AC5 assertions. +- ADR, upgrade instructions, single-namespace regression snapshot of the *removed* shape. + +## Pre-investigated edge cases + +| Edge case | Disposition | Notes | +| --------- | ----------- | ----- | +| Same bare table name in two namespaces (`public.users` + `auth.users`) | **Must be in the fixture** | Project AC1. The integration fixture must carry the same bare name in both namespaces so namespace-qualified resolution actually *discriminates* — a fixture with distinct names per namespace would pass even if qualification were broken (failure-mode F13: a regression test must fail under ¬P). | +| A namespace id that collides with a flat table name | Namespace id wins on the namespaced path; flat path unchanged | Additive intersection means `prop` is checked against `storage.namespaces` first. Document; do not add normalization magic (failure-mode F2). | + +## Slice-specific done conditions + +- [ ] Multi-namespace PGlite integration test exercises select / insert / update / delete on both namespaces and the cross-namespace FK relation via the explicit accessors, with the same bare table name present in both namespaces. + +## Open Questions + +1. Should the namespaced facet also be reachable as a top-level import (`sql.public`) in addition to through the facade `db.sql.public`? Working position: yes — `db.sql` *is* the builder `Db`, so both are the same object; no extra work. + +## References + +- Parent project: `projects/explicit-namespace-dsl/spec.md` +- Linear: [TML-2816](https://linear.app/prisma-company/issue/TML-2816) +- Prerequisite (merged): TML-2605 runtime-qualification — `resolveStorageTable`, `TableProxyImpl(namespaceId)`, two-plane namespaced IR. +- Calibration: `drive/calibration/failure-modes.md` F2 (no constructor magic), F13 (regression test must discriminate). diff --git a/projects/explicit-namespace-dsl/slices/02-remove-flat-fallback/spec.md b/projects/explicit-namespace-dsl/slices/02-remove-flat-fallback/spec.md new file mode 100644 index 0000000000..347df91466 --- /dev/null +++ b/projects/explicit-namespace-dsl/slices/02-remove-flat-fallback/spec.md @@ -0,0 +1,48 @@ +# Slice: remove flat fallback + facade projection (breaking cut) + +_(Parent project `projects/explicit-namespace-dsl/`. Contributes: the builder surface becomes **always-qualified** and the facade projects the right ergonomic shape per target — the breaking half of the additive-then-cut split. Dispatch plan deferred until slice 01 merges and the exact removal surface is grep-known.)_ + +## At a glance + +Removes the flat builder-layer accessors (`sql.
` / `orm.`) added-around in slice 01, leaving the namespaced facets as the only shape, and introduces the single `defaultNamespaceId`-keyed facade projection helper: unbound targets alias `db.sql = sql.__unbound__` / `db.orm = orm.__unbound__` to preserve flat ergonomics; non-unbound targets expose the qualified surface only. Ships the ADR and upgrade instructions. This is the deliberate breaking change (project FR6). + +## Chosen design + +- **Builder cut:** `Db` collapses to the mapped half only — `{ [Ns in keyof storage.namespaces]: Namespace }`; the flat intersection member and `sql()`'s flat fallback branch (`resolveTableForFlatName`) are removed. Mirror for the ORM client. After the cut, `TableNamesAcrossNamespaces` / `UnboundTables` flat indexing is dead and is deleted. +- **Facade projection (single shared helper, no per-target switch):** keyed solely on `defaultNamespaceId === UNBOUND_NAMESPACE_ID`. Unbound → `db.sql = sql.__unbound__`, `db.orm = orm.__unbound__` (flat shape recovered through the namespace facet). Non-unbound → `db.sql = sql`, `db.orm = orm` (qualified shape required at call sites). Wired identically into postgres / sqlite / mongo facades (project AC4 / AC5). +- **F1 watch:** removal must not relocate the flat fallback under a new name (failure-mode F1). Grep gates: `looksLikeFlat|normalizeStorageForHydration|resolveTableForFlatName` returns no surviving dual-shape accommodation; `'columns' in` discriminator probes absent from the new path. + +## Coherence rationale + +One reviewable PR = "the flat fallback is gone and the facade projects the end-state shape." The removal + the projection are inseparable: the projection helper only does work (alias vs qualified-only) once flat is removed, so they ship together. + +## Scope + +**In:** flat-accessor removal in `sql-builder` + `sql-orm-client`; the `defaultNamespaceId`-keyed projection helper wired into all three facades; negative type tests (project AC3); type-level projection assertions on the three packs (AC4 / AC5); ADR draft in this project dir; upgrade instructions (`record-upgrade-instructions`); single-namespace regression confirming the alias path; merge-candidate gate. + +**Out:** anything delivered by slice 01; ADR final-home migration (project-close M2); emitter / `contract.json` shape changes (project FR7 — none). + +## Pre-investigated edge cases + +| Edge case | Disposition | Notes | +| --------- | ----------- | ----- | +| Internal consumers that call `orm.` / `sql.
` flat on a multi-namespace contract | Must be migrated to qualified in this slice | Upgrade-instructions scope; grep `packages/3-extensions/*` + `examples/` for flat bypass sites. | +| Dual-shape relocated under a new name during removal | Hard stop (F1) | Grep gates above are dispatch DoD. | +| Unknown namespace id from a runtime-widened contract | Fail fast with a diagnostic naming the namespace (project FR11) | Lands with the removal of the flat fallback path. | +| Flat multi-namespace access currently **throws** (interim, from slice 01's ns-required refactor) | bare=default replaces the throw with default-namespace resolution | Slice 01 made flat `orm.` on a multi-namespace contract fail-fast via `soleDomainNamespaceId` (over the old silent first-match). When this slice lands bare=default at the facade, **retarget the throw-asserting tests** (`orm-namespaced.test.ts`, `orm-namespace-resolution.test.ts`, `namespace-qualification.test.ts`) to assert default-namespace resolution. | +| `resolvePrimaryKeyColumn`'s `'id'` fallback can't be discriminated when both same-bare-named tables share PK column `'id'` | Add a fixture with **differing PK column names** per namespace | Surfaced in slice-01 refactor review: the same-bare-table-name discrimination suite (cols `email` vs `token`) can't catch a coordinate miswire on the PK path because both fixtures use PK `'id'`. A differing-PK-name fixture tightens it. | + +## Slice-specific done conditions + +- [ ] Negative type tests confirm flat `orm.` / `sql.
` are gone; facade projection type-tests pass on postgres (qualified) + sqlite/mongo (flat-via-alias); the no-per-target-switch grep gate is clean. +- [ ] ADR drafted in `projects/explicit-namespace-dsl/`; upgrade instructions recorded. + +## Open Questions + +1. Exact upgrade-instructions scope (which internal extension packs bypass the facade). Working position: only consumers calling `orm`/`sql` directly on multi-namespace contracts; confirm via grep at slice start, widen if `packages/3-extensions/*` surfaces bypass sites. + +## References + +- Parent project: `projects/explicit-namespace-dsl/spec.md` (AC3, AC4, AC5, AC7; FR6, FR11) +- Depends on: slice 01 (additive surface) merged. +- Calibration: `drive/calibration/failure-modes.md` F1 (dual-shape relocated), F14 (gates mirror CI); `drive/calibration/grep-library.md` (IR substrate hygiene patterns). diff --git a/projects/explicit-namespace-dsl/spec.md b/projects/explicit-namespace-dsl/spec.md index d2acc24c82..00274b9b81 100644 --- a/projects/explicit-namespace-dsl/spec.md +++ b/projects/explicit-namespace-dsl/spec.md @@ -1,6 +1,6 @@ # Summary -This constituent project adds the **explicit namespace-aware DSL/ORM query surface** — `db.sql..
` and `db..` — so multi-namespace contracts can navigate to any namespace by name. It is a **launch blocker** for the [Supabase integration](../supabase-integration/README.md) ([TML-2503](https://linear.app/prisma-company/issue/TML-2503)): Supabase exposes colliding table names across namespaces (`auth.users` alongside `public.users`), and the flat-by-name default-namespace fallback from [runtime-qualification](../target-extensible-ir-namespaces/spec.md) ([TML-2605](https://linear.app/prisma-company/issue/TML-2605)) resolves only a single default namespace per bare name. Without explicit qualification there is no way to reach `auth.users`; everything collapses into one namespace. The surface is **purely additive** on that fallback — default-namespace consumers (`db.sql.
`, `db.`) see zero churn. +This project adds **explicit namespace-aware DSL/ORM accessors** — `orm..` and `sql..
` — so multi-namespace contracts can navigate to any namespace by name. The builder surface is **always qualified**: there is no flat default-namespace fallback at the `orm` / `sql` layer. Ergonomic flat access (`db.`) is handled by **orchestration facades**, which alias `db` to a chosen namespace facet (e.g. `db = orm.`) on targets that do not support multiple namespaces. **Linear:** [TML-2550](https://linear.app/prisma-company/issue/TML-2550) @@ -8,7 +8,7 @@ This constituent project adds the **explicit namespace-aware DSL/ORM query surfa ## At a glance -A Supabase-shaped app contract spans at least `public` (app models) and `auth` (extension-pack models). Both namespaces can expose a `users` table. Authoring uses PSL namespace blocks; querying uses the TS runtime: +A contract spans multiple namespaces. Authoring uses PSL namespace blocks; querying uses the TS runtime: ```prisma // app/prisma/schema.prisma — authoring (PSL) @@ -17,29 +17,42 @@ namespace public { id String @id @default(uuid()) userId String @unique - user supabase:auth.User @relation(fields: [userId], references: [id], onDelete: Cascade) + user auth.User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("profile") } } + +namespace auth { + model User { + id String @id @default(uuid()) + email String @unique + + @@map("users") + } +} ``` ```ts -// app/handlers.ts — runtime (TS; DSL/ORM surface is TS-only) -import { db } from './db'; +// app/handlers.ts — multi-namespace target (e.g. Postgres): always qualified +import { orm, sql } from './db'; -// Explicit namespace navigation (this project): -await db.sql.public.profile.select({ id: true }).build().execute(); -await db.sql.auth.users.select({ id: true, email: true }).build().execute(); -await db.public.Profile.find({ where: { id: profileId } }); -await db.auth.User.find({ where: { id: userId } }); +await sql.public.profile.select({ id: true }).build().execute(); +await sql.auth.users.select({ id: true, email: true }).build().execute(); +await orm.public.Profile.find({ where: { id: profileId } }); +await orm.auth.User.find({ where: { id: userId } }); +``` -// Default-namespace fallback (TML-2605) — unchanged for single-namespace apps: -await db.sql.profile.select({ id: true }).build().execute(); +```ts +// app/handlers.ts — single-namespace target (e.g. SQLite): facade aliases `db` +import { db } from './db'; + +// `db` here is `orm.` — flat shape preserved at the call site, +// but the same explicit-namespace machinery underneath. await db.Profile.find({ where: { id: profileId } }); ``` -After [TML-2605](../target-extensible-ir-namespaces/spec.md) lands, flat `db.sql.users` resolves through the per-family default namespace (`public` on Postgres). That is sufficient when only one namespace owns the bare name `users`. It is **not** sufficient when `auth.users` and `public.users` both exist — the fallback cannot disambiguate. This project adds the nested accessor path that names the namespace explicitly. +[TML-2605](../target-extensible-ir-namespaces/spec.md) provides the runtime identifier-qualification machinery (qualified SQL emission for `"auth"."users"` vs `"public"."users"`). This project consumes that machinery to build the per-namespace facets on `orm` and `sql`, and removes any flat default-namespace fallback at the builder layer — flat ergonomics become a facade concern. ```text Contract IR (nested namespaces on domain + storage planes) @@ -47,51 +60,50 @@ Contract IR (nested namespaces on domain + storage planes) ▼ TML-2605 — runtime-qualification (PREREQUISITE) • Runtime SQL qualifies identifiers by namespace coordinate - • Flat db.sql.
/ db. via default-namespace fallback + • Provides the qualification helpers explicit accessors call into │ ▼ TML-2550 — explicit-namespace-dsl (THIS PROJECT) - • db.sql..
/ db.. — additive explicit path - • Reuses TML-2605 identifier-qualification machinery under the hood - │ - ▼ -TML-2503 — extension-supabase (CONSUMER; launch blocked without this) - • Example app queries auth.* and public.* by explicit namespace + • orm.. / sql..
— per-namespace facets on builders + • Builder surface is always qualified (no flat fallback) + • Facade aliases `db` to a namespace facet on single-namespace targets ``` ## Problem -**1. Colliding bare names are real in Supabase, not theoretical.** Postgres schemas `auth` and `public` both expose a `users` table. The framework must let app code query `auth.users` for admin flows and `public.profile` (with FK to `auth.users`) without renaming tables or abandoning namespaces. +**1. Non-default namespaces are unreachable without explicit accessors.** A contract that exposes `auth.User` and `public.Profile` has no way to query the `auth` namespace if the only accessor shape is `orm.` keyed off a single default namespace. -**2. The default-namespace fallback is intentionally single-target.** [TML-2605](https://linear.app/prisma-company/issue/TML-2605) preserves backward compatibility: `db.sql.
` resolves through one per-family default namespace. That design is correct for legacy single-namespace consumers and wrong for multi-namespace disambiguation — expanding the fallback to "try every namespace until one matches" would be ambiguous at runtime and unsound at the type level. +**2. A builder-level default-namespace fallback is the wrong primitive.** It conflates navigation (which namespace's table?) with ergonomics (don't make me type the namespace when there's only one). Bundling those into one fallback at the builder layer forces awkward decisions whenever both concerns appear together, and it produces a surface that is asymmetric between targets that have namespaces and targets that don't. -**3. The explicit path must be ergonomic where collisions exist.** Supabase guarantees collisions in practice. A surface that merely *allows* `db.sql.auth.users` but still forces awkward workarounds on flat keys (or fails compilation for entire contracts) does not unblock launch. The open design decision (see [Open Questions](#open-questions)) is how flat-by-name keys behave when multiple namespaces share a name — the chosen option must make the collision case pleasant, not only legal. +**3. Single-namespace ergonomics belong at the facade layer.** When a target has no concept of multiple namespaces, users still want `db.Profile.find(...)`. That shape is achievable without a builder-level fallback: the orchestration facade simply aliases `db = orm.`. ## Approach -### Purely additive on TML-2605 +### Always-qualified builder surface -Three properties: +The ORM and SQL builders expose namespace facets directly: `orm..`, `sql..
`. There is no `orm.` shape and no `sql.
` shape at the builder layer — namespace selection is mandatory. Namespace identifiers match contract IR namespace keys (`public`, `auth`, `__unbound__`, etc.). -- **No change to default-namespace call sites.** Code that today uses `db.sql.profile` / `db.Profile` continues to compile and resolve identically after this project merges, verified by regression tests against single-namespace fixtures. -- **Explicit accessors reuse the same qualification path.** Runtime resolution for `db.sql.auth.users` should not fork a parallel identifier pipeline — it names a namespace coordinate and delegates to the same machinery TML-2605 uses when emitting `"auth"."users"` vs `"public"."users"`. -- **Type-level `Db` grows a per-namespace facet, not a breaking reshape.** `Db` walks `contract..namespaces` to produce namespace-keyed intermediates (`db.sql.`, `db.`) before table/model keys. The inferred type must remain tractable (see NFR2). +### Facade-layer ergonomic defaults -### SQL DSL shape +Orchestration facades that expose a unified `db` entry point project the builder surface based on whether the target supports multiple namespaces. The discriminator is already in each target's descriptor: `defaultNamespaceId === UNBOUND_NAMESPACE_ID` iff the target is single-namespace. -`db.sql..` returns the same table-proxy kind the flat path returns today, but pinned to the named namespace's storage coordinate. Namespace identifiers match contract IR namespace keys (`public`, `auth`, `__unbound__`, etc.). +Concrete targets in this repo at the time of writing: -### ORM shape +| Target | Package | `defaultNamespaceId` | Facade projection | +|---|---|---|---| +| **Postgres** | `packages/3-targets/3-targets/postgres` | `'public'` | `db = orm` — fully qualified shape required (`db..`, `db.sql..
`). | +| **SQLite** | `packages/3-targets/3-targets/sqlite` | `'__unbound__'` | `db = orm.__unbound__` — flat shape preserved (`db.`, `db.sql.
`). | +| **Mongo** | `packages/3-mongo-target/1-mongo-target` | `'__unbound__'` | `db = orm.__unbound__` — flat shape preserved. | -`db..` mirrors the SQL shape on the domain plane: PascalCase model keys per namespace, consistent with how models are grouped under `contract.domain.namespaces..models` after the [domain-plane](../target-extensible-ir-namespaces/slices/symmetric-domain-plane/spec.md) slice. +The ergonomic "don't make me type the namespace" decision lives in the facade, where it composes with other facade concerns (session binding, multi-tenant scoping, etc.) without distorting the builder type construction. New targets fall into the appropriate column by the same rule: a non-unbound `defaultNamespaceId` declares the target as multi-namespace and inherits the qualified projection. -### Collision behaviour (decision at pickup) +### Runtime resolution -With nested namespace IR, `auth.users` and `public.users` are both representable. How **flat** `db.sql.users` behaves when both exist is unsettled — see [Open Questions](#open-questions). This project implements the explicit path regardless; the decision affects whether flat keys union, error, or defer to explicit qualification only on collision. +Per-namespace accessors delegate to the identifier-qualification helpers introduced for TML-2605, parameterized by namespace coordinate. There is no parallel qualification pipeline. ### Verification story -A multi-namespace example (Supabase-shaped: `auth.users` + `public.users` + a `public.profile` FK) must be authorable (PSL), emittable (`contract.json`), and queryable end-to-end (PGlite or in-memory Postgres). The [extension-supabase](../extension-supabase/spec.md) example app is the long-term home; this project may land a focused fixture first if that unblocks integration work earlier. +A multi-namespace fixture (two namespaces, one model in each, an FK across them) is authorable (PSL), emittable (`contract.json`), and queryable end-to-end via PGlite. A separate single-namespace fixture on a non-namespace-supporting adapter exercises the facade-alias path. # Requirements @@ -99,112 +111,96 @@ A multi-namespace example (Supabase-shaped: `auth.users` + `public.users` + a `p ### Explicit SQL accessors -- **FR1.** For contracts with multiple storage namespaces, `db.sql..` resolves to the table in that namespace and produces namespace-qualified SQL on execute (e.g. `"auth"."users"` on Postgres). -- **FR2.** Namespace identifiers exposed on `db.sql` match the contract's storage namespace keys. Unknown namespace ids are a compile-time error on the typed surface (or a fail-fast runtime error if the contract JSON is widened). -- **FR3.** Explicit SQL accessors support the same query-builder operations as the flat table proxies from TML-2605 (select/insert/update/delete/join paths already available on table proxies). +- **FR1.** `sql..` resolves to the table in that namespace and produces namespace-qualified SQL on execute (e.g. `"auth"."users"` on Postgres). +- **FR2.** Namespace identifiers exposed on `sql` match the contract's storage namespace keys. Unknown namespace ids are a compile-time error on the typed surface (or a fail-fast runtime error if the contract JSON is widened). +- **FR3.** Explicit SQL accessors support the same query-builder operations the table proxies support today (select/insert/update/delete/join paths). ### Explicit ORM accessors -- **FR4.** For contracts with multiple domain namespaces, `db..` resolves to the model accessor in that namespace (find/create/update/delete APIs already on the flat ORM surface). -- **FR5.** ORM namespace keys align with `contract.domain.namespaces` keys and model names align with domain model keys within each namespace. +- **FR4.** `orm..` resolves to the model accessor in that namespace (find/create/update/delete APIs on the model accessor surface). +- **FR5.** ORM namespace keys align with `contract.domain.namespaces` keys; model names align with domain model keys within each namespace. -### Backward compatibility +### Builder surface is always qualified -- **FR6.** Single-default-namespace contracts: flat `db.sql.
` and `db.` behave identically before and after this change (no call-site edits required). +- **FR6.** The ORM and SQL builder surfaces (`orm`, `sql`) expose no flat default-namespace accessors. `orm.` and `sql.
` at the builder layer are removed; namespace selection is always explicit. This is a deliberate breaking change against any pre-existing flat-fallback behaviour from TML-2605. - **FR7.** No breaking change to emitted `contract.json` or `contract.d.ts` shape — this project only extends runtime/typing surfaces. +### Facade-layer ergonomic defaults + +- **FR8.** Orchestration facades that expose `db` project the builder surface based on target capability: + - Target supports multiple namespaces: `db = orm` (qualified shape required at call sites). + - Target does not support multiple namespaces: `db = orm.` (flat shape preserved at call sites by aliasing). + The same projection rule applies to `db.sql`. +- **FR9.** The facade-alias path produces the same runtime behaviour as the direct namespaced path — it is a binding, not a second code path. + ### Runtime resolution -- **FR8.** Runtime table/model lookup by explicit namespace uses the same identifier-qualification helper(s) introduced for TML-2605, parameterized by namespace coordinate — no second qualification implementation. -- **FR9.** Mis-typed namespace or table/model name fails fast with a diagnostic that names the namespace and suggests the explicit path when flat resolution is ambiguous (exact message left to implementer; must not silently hit the wrong table). +- **FR10.** Runtime table/model lookup by explicit namespace uses the identifier-qualification helper(s) introduced for TML-2605, parameterized by namespace coordinate. +- **FR11.** Mis-typed namespace or table/model name fails fast with a diagnostic that names the namespace. ### Demonstration -- **FR10.** A committed multi-namespace example or integration test exercises: authoring in PSL with `namespace public { … }` plus extension-pack `auth` models; emit contract; query via `db.sql.auth.users` and `db.sql.public.profile` (or equivalent ORM calls) in one test run. +- **FR12.** A committed multi-namespace integration test exercises: authoring a contract with two namespaces in PSL; emit contract; query both namespaces via explicit accessors in one test run. +- **FR13.** A single-namespace integration test on a non-namespace-supporting adapter exercises the facade-alias path (`db.` resolving through `orm..`). ## Non-Functional Requirements -- **NFR1.** Hot-path cost for flat default-namespace lookups is unchanged — explicit namespace routing adds no overhead to call sites that never use `db.sql.`. -- **NFR2.** `Db` inferred type size remains buildable: if explicit per-namespace facets blow TypeScript inference past practical limits, the implementer documents the mitigation (namespace allowlist, type simplification) in an ADR — see [Open Questions](#open-questions). +- **NFR1.** Per-namespace facet construction adds no measurable overhead to query execution; facets are thin proxies over existing table/model accessors. +- **NFR2.** `Db` inferred type size remains buildable for realistic contracts; if the per-namespace facet pattern strains TypeScript inference, mitigations are documented in the ADR. - **NFR3.** `pnpm lint:deps` passes; namespace accessor construction lives in the existing DSL/ORM client packages without new layering violations. -- **NFR4.** Test coverage: unit tests for type-level namespace keys (negative tests for unknown `ns`), integration tests for qualified SQL text on explicit paths, regression tests for FR6. +- **NFR4.** Test coverage: unit tests for type-level namespace keys (negative tests for unknown `ns`), integration tests for qualified SQL text on explicit paths, integration tests for the facade-alias path on a single-namespace adapter. ## Non-goals -- **Changing TML-2605's default-namespace fallback semantics** beyond what the collision-behaviour decision requires — prerequisite work stays in [runtime-qualification](../target-extensible-ir-namespaces/spec.md). -- **PSL syntax for namespace-qualified queries** — there is no PSL query surface; qualification in authoring remains namespace blocks + cross-contract refs ([B6](../supabase-integration/decisions.md)). -- **Supabase runtime role binding, JWT, `SET LOCAL`** — [extension-supabase](../extension-supabase/spec.md) / [runtime-target-layer](../runtime-target-layer/spec.md). -- **Cross-contract-space FK authoring** — [cross-contract-refs](../cross-contract-refs/spec.md). -- **Per-namespace `contract.d.ts` emission redesign** — emitter may stay single-file; explicit accessors are a runtime/typing concern unless pickup discovers emitter coupling. +- **Builder-level default-namespace fallback** — removed by this project; flat ergonomics are a facade concern only. +- **PSL syntax for namespace-qualified queries** — qualification in authoring remains namespace blocks + cross-contract refs. +- **Cross-contract-space FK authoring** — separate project. +- **Per-namespace `contract.d.ts` emission redesign** — emitter may stay single-file; explicit accessors are a runtime/typing concern. ## Sequencing constraints | Constraint | Detail | |---|---| | **Hard prerequisite** | [TML-2605](https://linear.app/prisma-company/issue/TML-2605) (runtime-qualification) must merge first. This project reuses its identifier-qualification path. | -| **Umbrella placement** | Constituent of [Supabase integration](../supabase-integration/README.md); **launch blocker** for [TML-2503](https://linear.app/prisma-company/issue/TML-2503). | -| **Parallelism** | After TML-2605 lands, this project can run in parallel with other umbrella constituents that do not touch `Db` accessor construction. It does **not** gate [target-extensible-ir-namespaces](../target-extensible-ir-namespaces/spec.md) close-out (explicit-dsl was elevated out for that reason). | -| **Delivery shape** | One PR, ~2–3 days engineering effort. | +| **Delivery shape** | One PR; effort sized at pickup. | # Acceptance Criteria -- [ ] **AC1.** `db.sql..
` works for explicit multi-namespace navigation, including querying `auth.users` when `public.users` also exists in the same contract aggregate. -- [ ] **AC2.** `db..` works for explicit multi-namespace ORM navigation with the same namespace keys as the SQL surface. -- [ ] **AC3.** Default-namespace consumers (`db.sql.
`, `db.` without an intermediate namespace key) see zero churn — existing demo queries and regression fixtures pass unchanged. -- [ ] **AC4.** A Supabase-shaped multi-namespace fixture is authorable (PSL), emittable, and queryable end-to-end (explicit paths used for the colliding `users` table). -- [ ] **AC5.** Collision-behaviour decision (Open Questions) is recorded in an ADR if execution surfaces enough design content; implementation matches the chosen option. -- [ ] **AC6.** `pnpm test:packages` and relevant integration tests green; `pnpm lint:deps` passes. +- [ ] **AC1.** `sql..
` works for explicit multi-namespace navigation, including the case where the same bare table name appears in more than one namespace (e.g. `auth.users` and `public.users`) within one contract. +- [ ] **AC2.** `orm..` works for explicit multi-namespace ORM navigation with namespace keys aligned to the SQL surface. +- [ ] **AC3.** Builder-level flat accessors (`orm.`, `sql.
`) are removed; type-level negative tests confirm. +- [ ] **AC4.** Facade projection per concrete target matches the rule, covered by integration tests: + - **Postgres** (`packages/3-targets/3-targets/postgres`, `defaultNamespaceId: 'public'`): `db` exposes the qualified shape (`db..`, `db.sql..
`); flat `db.` and `db.sql.
` are absent. + - **SQLite** (`packages/3-targets/3-targets/sqlite`, `defaultNamespaceId: '__unbound__'`): `db` is aliased to `orm.__unbound__` (and `sql.__unbound__`); flat `db.` and `db.sql.
` resolve through the alias. + - **Mongo** (`packages/3-mongo-target/1-mongo-target`, `defaultNamespaceId: '__unbound__'`): same flat-aliased projection as SQLite, against the Mongo ORM/builder surface. +- [ ] **AC5.** Facade projection is driven solely by the target descriptor's `defaultNamespaceId` (unbound ↔ flat alias; non-unbound ↔ qualified), with no per-target switch in the facade implementation. A type-level test asserts this on the three targets above. +- [ ] **AC6.** A multi-namespace fixture is authorable (PSL), emittable, and queryable end-to-end via the explicit accessor path (run on Postgres via PGlite). +- [ ] **AC7.** ADR captures the always-qualified builder surface, the facade-aliasing pattern, and the `Db` per-namespace facet construction. +- [ ] **AC8.** `pnpm test:packages` and relevant integration tests green; `pnpm lint:deps` passes. # Other Considerations ## TypeScript-only query surface -The DSL/ORM accessors are runtime TypeScript API. PSL leads for **authoring** (namespace blocks, models, policies); TS examples in this spec illustrate **query** usage only. That matches [prefer-psl-in-design-docs](../../.agents/rules/prefer-psl-in-design-docs.mdc) ordering: PSL for contract shape, TS where the capability is TS-only. - -## Relationship to extension-supabase - -[extension-supabase](../extension-supabase/spec.md) wraps the runtime in a role-bound facade (`asUser` / `asAnon` / `asServiceRole`). Explicit namespace accessors must compose through `RoleBoundDb` unchanged — role binding selects session context; namespace selection selects which table coordinate to query. No Supabase-specific fork of the accessor types. - -## Cost +The DSL/ORM accessors are runtime TypeScript API. PSL leads for **authoring** (namespace blocks, models, policies); TS examples illustrate **query** usage only. That matches [prefer-psl-in-design-docs](../../.agents/rules/prefer-psl-in-design-docs.mdc): PSL for contract shape, TS where the capability is TS-only. -Touches primarily: +## Facade aliasing as the integration extension point -- DSL accessor type construction (`Db` walking `contract.storage.namespaces`), -- ORM accessor type construction (domain namespaces), -- Runtime resolution wiring into TML-2605 helpers. - -Estimated ~2–3 days, one reviewable PR. Upgrade instructions only if a breaking type change surfaces (working assumption: none). +Downstream integrations (extension packs that bind sessions to roles, multi-tenant facades, etc.) compose by wrapping the facade `db` — not by reshaping the builder surface. The always-qualified builder layer plus facade-driven ergonomic projection gives integrations a stable shape to wrap. # References -- [Umbrella — Supabase integration](../supabase-integration/README.md) -- [Umbrella overview — end-to-end narrative](../supabase-integration/overview.md) -- [Umbrella `decisions.md` — B6 reopenable namespace blocks](../supabase-integration/decisions.md) -- [TML-2550](https://linear.app/prisma-company/issue/TML-2550) — this constituent (explicit namespace-aware DSL/ORM) +- [TML-2550](https://linear.app/prisma-company/issue/TML-2550) — this project - [TML-2605](https://linear.app/prisma-company/issue/TML-2605) — prerequisite (runtime-qualification) -- [TML-2503](https://linear.app/prisma-company/issue/TML-2503) — blocked consumer (extension-supabase) -- [target-extensible-ir-namespaces](../target-extensible-ir-namespaces/spec.md) — IR + runtime-qualification umbrella; explicit-dsl elevated out -- [extension-supabase](../extension-supabase/spec.md) — integration package and example app +- [target-extensible-ir-namespaces](../target-extensible-ir-namespaces/spec.md) — IR + runtime-qualification - [ADR 221 — Contract IR two planes](../../docs/architecture%20docs/adrs/ADR%20221%20-%20Contract%20IR%20two%20planes%20with%20uniform%20entity%20coordinate%20and%20pack-contributed%20entity%20kinds.md) — namespace envelope shape explicit accessors reflect # Open Questions -## Cross-namespace flat-by-name collision behaviour (resolve at pickup) - -When both `auth.users` and `public.users` exist, how should **flat** `db.sql.users` / `db.users` behave? - -| Option | Behaviour | Tradeoff | -|---|---|---| -| **(A) Union row types** | `db.sql.users` becomes `TableProxy \| TableProxy` (ORM analogue for models). Narrow at use site. | Preserves flat ergonomics; pushes disambiguation to application code; TS complexity. | -| **(B) Qualify-on-collision only** | Flat key when globally unique; when colliding, flat key absent or untyped and explicit `db.sql.auth.users` required. | Simple mental model; flat path disappears exactly where Supabase needs help. | -| **(C) Compile-error on collision** | Contracts with cross-namespace name collisions fail typecheck unless all query sites use explicit namespaces. | Forces explicit qualification at authoring; may be heavy-handed for large contracts. | - -**Headline requirement:** Supabase guarantees collisions exist in practice — the chosen option must make the collision case **ergonomic**, not merely legal. Decision lands in this issue's ADR if execution surfaces enough design content. - ## ADR scope -Does this project produce a long-lived ADR for the namespace-aware DSL/ORM surface (beyond the collision decision)? **Working assumption: yes** if the collision decision or `Db` construction pattern establishes conventions future families/extensions must follow. +Confirmed: this project produces a long-lived ADR covering (a) the always-qualified builder surface, (b) the facade-aliasing pattern for ergonomic defaults on single-namespace targets, and (c) the `Db` per-namespace facet construction. ## Example placement -Does the Supabase-shaped fixture live in this PR or only in extension-supabase? **Working assumption:** minimal integration test here; full example app stays with [TML-2503](https://linear.app/prisma-company/issue/TML-2503). +Does the multi-namespace fixture live in this PR or in a downstream consumer? **Working assumption:** a minimal multi-namespace integration test in this PR (PGlite), plus a single-namespace integration test against a non-namespace-supporting adapter to cover the facade-alias path. Richer end-to-end example apps stay with their respective consumers. diff --git a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.12-to-0.13/instructions.md b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.12-to-0.13/instructions.md index 78537399f5..f29d5ecf39 100644 --- a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.12-to-0.13/instructions.md +++ b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.12-to-0.13/instructions.md @@ -14,6 +14,15 @@ changes: contains: - '"typeParams": {}' anyMatch: true + - id: thread-namespace-id-through-codec-ref-resolver-spi + summary: | + The codec-resolution SPI in `@prisma-next/sql-relational-core` now takes a leading, required `namespaceId` coordinate. The `CodecDescriptorRegistry.codecRefForColumn(table, column)` build-time helper — the one AST authors call to stamp `codec` onto every column-bound `ParamRef` / `ProjectionItem`, exported from `@prisma-next/sql-relational-core/query-lane-context` and `@prisma-next/sql-relational-core/codec-descriptor-registry` — is now `codecRefForColumn(namespaceId, table, column)`. The underlying free function `codecRefForStorageColumn(storage, table, column)` (exported from `@prisma-next/sql-relational-core/codec-descriptor-registry`) is now `codecRefForStorageColumn(storage, namespaceId, table, column)`. Extension authors who derive codec refs directly must thread the namespace the table sits in at every call site: pass the explicit `namespaceId` ahead of `table`. There is no codemod — the right namespace is call-site-specific (read it from the model/table you are building the ref for). Two same-bare-named tables in different namespaces now resolve to their own per-namespace columns/codecs instead of the first scan hit. + detection: + glob: "**/*.{ts,tsx}" + contains: + - "codecRefForColumn(" + - "codecRefForStorageColumn(" + anyMatch: true ---