Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion packages/2-sql/9-family/src/core/control-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
TargetDescriptor,
} from '@prisma-next/framework-components/components';
import type {
ContractSpace,
ControlFamilyInstance,
ControlStack,
CoreSchemaView,
Expand Down Expand Up @@ -463,6 +464,33 @@ export function assertNoCrossSpaceFkReverseReferences(
}
}

const PUBLIC_NAMESPACE_ID = 'public';

/**
* Collects the DB table names each extension pack claims in its `public`
* namespace. `contract infer` introspects the `public` schema only and
* introspected tables carry no schema qualifier, so only `public`-namespace
* claims are eligible to suppress an introspected table — a pack claiming
* `auth.users` must never suppress an app's `public.users`.
*/
function collectClaimedPublicTableNames(
extensions: readonly { readonly contractSpace?: ContractSpace<Contract<SqlStorage>> }[],
): ReadonlySet<string> {
const claimed = new Set<string>();
for (const extension of extensions) {
const namespaces = extension.contractSpace?.contractJson.storage.namespaces;
for (const namespace of Object.values(namespaces ?? {})) {
if (namespace.id !== PUBLIC_NAMESPACE_ID) {
continue;
}
for (const tableName of Object.keys(namespace.entries.table ?? {})) {
claimed.add(tableName);
}
}
}
return claimed;
}

export function createSqlFamilyInstance<TTargetId extends string>(
stack: ControlStack<'sql', TTargetId>,
): SqlFamilyInstance {
Expand Down Expand Up @@ -890,7 +918,9 @@ export function createSqlFamilyInstance<TTargetId extends string>(
},

inferPslContract(schemaIR: SqlSchemaIR): PslDocumentAst {
return sqlSchemaIrToPslAst(schemaIR);
return sqlSchemaIrToPslAst(schemaIR, {
claimedTableNames: collectClaimedPublicTableNames(extensions),
});
},

lowerAst(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,21 @@ type TopLevelNameResult = {
readonly map?: string | undefined;
};

export type SqlSchemaIrToPslAstOptions = {
readonly claimedTableNames?: ReadonlySet<string>;
};

/**
* Converts a SQL schema IR into a PSL AST suitable for `printPsl`.
*
* This function owns all SQL-specific concerns: native type mapping (Postgres),
* relation inference from foreign keys, enum extraction, and raw default parsing.
* The output is a fully-formed `PslDocumentAst` with synthetic spans.
*/
export function sqlSchemaIrToPslAst(schemaIR: SqlSchemaIR): PslDocumentAst {
export function sqlSchemaIrToPslAst(
schemaIR: SqlSchemaIR,
inferOptions?: SqlSchemaIrToPslAstOptions,
): PslDocumentAst {
const enumInfo = extractEnumInfo(schemaIR.annotations);
if (enumInfo.typeNames.size > 0) {
const names = [...enumInfo.typeNames].join(', ');
Expand All @@ -96,7 +103,34 @@ export function sqlSchemaIrToPslAst(schemaIR: SqlSchemaIR): PslDocumentAst {
parseRawDefault,
};

return buildPslDocumentAst(schemaIR, options);
const filteredSchemaIR = filterClaimedTables(schemaIR, inferOptions?.claimedTableNames);

return buildPslDocumentAst(filteredSchemaIR, options);
}

function filterClaimedTables(
schemaIR: SqlSchemaIR,
claimedTableNames: ReadonlySet<string> | undefined,
): SqlSchemaIR {
if (!claimedTableNames || claimedTableNames.size === 0) {
return schemaIR;
}

const tables = Object.fromEntries(
Object.entries(schemaIR.tables)
.filter(([tableName]) => !claimedTableNames.has(tableName))
.map(([tableName, table]) => {
const survivingForeignKeys = table.foreignKeys.filter(
(fk) => !claimedTableNames.has(fk.referencedTable),
);
if (survivingForeignKeys.length === table.foreignKeys.length) {
return [tableName, table];
}
return [tableName, { ...table, foreignKeys: survivingForeignKeys }];
}),
);

return { ...schemaIR, tables };
}

function buildPslDocumentAst(schemaIR: SqlSchemaIR, options: PslPrinterOptions): PslDocumentAst {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { computeStorageHash } from '@prisma-next/contract/hashing';
import { type Contract, coreHash, profileHash } from '@prisma-next/contract/types';
import type {
ContractSpace,
ControlFamilyDescriptor,
ControlStack,
} from '@prisma-next/framework-components/control';
import { createControlStack } from '@prisma-next/framework-components/control';
import { flatPslModels } from '@prisma-next/framework-components/psl-ast';
import { sqlContractCanonicalizationHooks } from '@prisma-next/sql-contract/canonicalization-hooks';
import { SqlStorage } from '@prisma-next/sql-contract/types';
import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
import { applicationDomainOf } from '@prisma-next/test-utils';
import { describe, expect, it } from 'vitest';
import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support';
import { createSqlFamilyInstance } from '../../src/core/control-instance';
import type { SqlControlExtensionDescriptor } from '../../src/core/migrations/types';

const TARGET = 'postgres' as const;
const TARGET_FAMILY = 'sql' as const;

function buildExtension(opts: {
readonly id: string;
readonly namespaceId: string;
readonly tables: Record<string, unknown>;
readonly namespaceKey?: string;
}): SqlControlExtensionDescriptor<'postgres'> {
const namespaceKey = opts.namespaceKey ?? opts.namespaceId;
const allTables = Object.fromEntries(
Object.entries(opts.tables).map(([name, columns]) => [
name,
{ columns, uniques: [], indexes: [], foreignKeys: [] },
]),
);

const hash = computeStorageHash({
target: TARGET,
targetFamily: TARGET_FAMILY,
storage: {
namespaces: {
[namespaceKey]: {
id: opts.namespaceId,
entries: { table: allTables },
},
},
},
...sqlContractCanonicalizationHooks,
});

const contract: Contract<SqlStorage> = {
target: TARGET,
targetFamily: TARGET_FAMILY,
roots: {},
domain: applicationDomainOf({ models: {} }),
capabilities: {},
extensionPacks: {},
meta: {},
profileHash: profileHash('fixture-profile-v1'),
storage: new SqlStorage({
storageHash: coreHash(hash),
namespaces: {
[namespaceKey]: createTestSqlNamespace({
id: opts.namespaceId,
entries: { table: allTables as never },
}),
},
}),
};

return {
kind: 'extension' as const,
id: opts.id,
familyId: 'sql' as const,
targetId: 'postgres' as const,
version: '0.0.1',
contractSpace: {
contractJson: contract,
migrations: [],
headRef: { hash: contract.storage.storageHash as string, invariants: [] },
} satisfies ContractSpace<Contract<SqlStorage>>,
create: () => ({ familyId: 'sql' as const, targetId: 'postgres' as const }),
};
}

function makeStack(
extensions: readonly SqlControlExtensionDescriptor<'postgres'>[],
): ControlStack<'sql', 'postgres'> {
return createControlStack({
family: {
kind: 'family',
id: 'sql',
familyId: 'sql',
version: '0.0.1',
create: (() => ({})) as unknown as ControlFamilyDescriptor<'sql'>['create'],
emission: {
id: 'sql',
generateStorageType: () => '{ readonly storageHash: StorageHash }',
generateModelStorageType: () => 'Record<string, never>',
getFamilyImports: () => [],
getFamilyTypeAliases: () => '',
getTypeMapsExpression: () => 'unknown',
getContractWrapper: (base: string) => `export type Contract = ${base};`,
},
},
target: {
kind: 'target',
id: 'postgres',
version: '0.0.1',
familyId: 'sql',
targetId: 'postgres',
contractSerializer: {
deserializeContract: (json) => json as never,
serializeContract: (contract) => contract as never,
},
create: () => ({ familyId: 'sql', targetId: 'postgres' }),
},
adapter: {
kind: 'adapter',
id: 'postgres',
version: '0.0.1',
familyId: 'sql',
targetId: 'postgres',
create: () => ({ familyId: 'sql', targetId: 'postgres' }),
},
extensionPacks: extensions,
});
}

function tableIr(name: string): SqlSchemaIR['tables'][string] {
return {
name,
columns: { id: { name: 'id', nativeType: 'int4', nullable: false } },
primaryKey: { columns: ['id'] },
foreignKeys: [],
uniques: [],
indexes: [],
};
}

function tableIrWithFk(
name: string,
fk: { readonly column: string; readonly referencedTable: string },
): SqlSchemaIR['tables'][string] {
return {
name,
columns: {
id: { name: 'id', nativeType: 'int4', nullable: false },
[fk.column]: { name: fk.column, nativeType: 'int4', nullable: false },
},
primaryKey: { columns: ['id'] },
foreignKeys: [
{ columns: [fk.column], referencedTable: fk.referencedTable, referencedColumns: ['id'] },
],
uniques: [],
indexes: [],
};
}

describe('inferPslContract extension awareness', () => {
it('omits a table an extension pack claims in its public namespace', () => {
const ext = buildExtension({
id: 'ext-owned',
namespaceId: 'public',
tables: { t_owned: { id: { codecId: 'pg/int4@1', nativeType: 'integer', nullable: false } } },
});
const instance = createSqlFamilyInstance(makeStack([ext]));

const schemaIR: SqlSchemaIR = {
tables: { app_table: tableIr('app_table'), t_owned: tableIr('t_owned') },
};

const ast = instance.inferPslContract(schemaIR);
const modelNames = flatPslModels(ast).map((m) => m.name);
expect(modelNames).toContain('AppTable');
expect(modelNames).not.toContain('TOwned');
});

it('keeps an introspected public table when the pack claims the same name in a non-public namespace', () => {
const ext = buildExtension({
id: 'ext-auth',
namespaceId: 'auth',
tables: { users: { id: { codecId: 'pg/int4@1', nativeType: 'integer', nullable: false } } },
});
const instance = createSqlFamilyInstance(makeStack([ext]));

const schemaIR: SqlSchemaIR = {
tables: { users: tableIr('users'), app_table: tableIr('app_table') },
};

const ast = instance.inferPslContract(schemaIR);
const modelNames = flatPslModels(ast).map((m) => m.name);
expect(modelNames).toContain('Users');
expect(modelNames).toContain('AppTable');
});

it('drops a surviving table field pointing at a claimed (omitted) table', () => {
const ext = buildExtension({
id: 'ext-owned',
namespaceId: 'public',
tables: { t_owned: { id: { codecId: 'pg/int4@1', nativeType: 'integer', nullable: false } } },
});
const instance = createSqlFamilyInstance(makeStack([ext]));

const schemaIR: SqlSchemaIR = {
tables: {
posts: tableIrWithFk('posts', { column: 'author_id', referencedTable: 't_owned' }),
t_owned: tableIr('t_owned'),
},
};

const ast = instance.inferPslContract(schemaIR);
const models = flatPslModels(ast);
const modelNames = models.map((m) => m.name);
expect(modelNames).toContain('Posts');
expect(modelNames).not.toContain('TOwned');

const fieldTypeNames = models.flatMap((m) => m.fields.map((f) => f.typeName));
expect(fieldTypeNames).not.toContain('TOwned');
expect(fieldTypeNames).not.toContain('t_owned');
});

it('matches the public namespace by its `.id`, not its record key', () => {
const ext = buildExtension({
id: 'ext-owned',
namespaceId: 'public',
namespaceKey: 'not-public',
tables: { t_owned: { id: { codecId: 'pg/int4@1', nativeType: 'integer', nullable: false } } },
});
const instance = createSqlFamilyInstance(makeStack([ext]));

const schemaIR: SqlSchemaIR = {
tables: { app_table: tableIr('app_table'), t_owned: tableIr('t_owned') },
};

const ast = instance.inferPslContract(schemaIR);
const modelNames = flatPslModels(ast).map((m) => m.name);
expect(modelNames).toContain('AppTable');
expect(modelNames).not.toContain('TOwned');
});
});
Loading
Loading