Skip to content
Merged
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
128 changes: 128 additions & 0 deletions pgpm/slice/__tests__/exclude.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { SchemaRouter } from '@pgpmjs/transform';

import { excludeSubsystem, loadModule } from '../src';

beforeAll(async () => {
await loadModule();
});

// A vendor-neutral fixture: an `identity` subsystem the consumer wants to
// replace with its own provider, plus an app that depends on a small part
// of it (one uuid PK table + one accessor function).
const SQL = `
CREATE SCHEMA identity;
CREATE TABLE identity.users (
id uuid PRIMARY KEY,
secret_token text,
recovery_token text
);
CREATE TABLE identity.sessions (
id bigserial PRIMARY KEY,
user_id uuid REFERENCES identity.users(id)
);
CREATE FUNCTION identity.current_actor() RETURNS uuid AS $$
select nullif(current_setting('request.claims.sub', true), '')::uuid;
$$ LANGUAGE sql STABLE;
CREATE FUNCTION identity.actor_label() RETURNS text AS $$
select nullif(current_setting('request.claims.label', true), '')::text;
$$ LANGUAGE sql STABLE;
GRANT SELECT ON TABLE identity.users TO web_user;

CREATE SCHEMA app;
CREATE TABLE app.posts (
id serial PRIMARY KEY,
owner uuid REFERENCES identity.users(id)
);
CREATE POLICY posts_owner ON app.posts USING (owner = identity.current_actor());
`;

const selector = { schemas: ['identity'] };

describe('excludeSubsystem', () => {
it('partitions subsystem statements from survivors', () => {
const res = excludeSubsystem(SQL, selector);

// schema + 2 tables + 2 functions + grant are inside
expect(res.excluded).toHaveLength(6);
// app schema + posts table + policy survive
expect(res.kept).toHaveLength(3);
});

it('measures the external contract: only what survivors actually use', () => {
const res = excludeSubsystem(SQL, selector);

const requiredKeys = res.contract.required
.map(d => `${d.object.schema}.${d.object.name}`)
.sort();
expect(requiredKeys).toEqual(['identity.current_actor', 'identity.users']);

const users = res.contract.required.find(d => d.object.name === 'users');
expect(users?.fk).toBe(true);
const actor = res.contract.required.find(d => d.object.name === 'current_actor');
expect(actor?.fk).toBe(false);

// implementation detail nothing outside touches
const internalNames = res.contract.internal.map(o => o.name).sort();
expect(internalNames).toContain('sessions');
expect(internalNames).toContain('actor_label');
});

it('reports every surviving reference as unsatisfied without rebinds', () => {
const res = excludeSubsystem(SQL, selector);

const names = [...new Set(res.unsatisfied.map(u => u.object.name))].sort();
expect(names).toEqual(['current_actor', 'users']);
expect(res.unsatisfied.some(u => u.fk)).toBe(true);
});

it('is satisfied when a router rebinds the full contract', () => {
const router = new SchemaRouter({
identity: {
relations: { users: { schema: 'app', name: 'users' } },
functions: { current_actor: { schema: null, name: 'current_user_id' } }
}
});
const res = excludeSubsystem(SQL, selector, { rebinds: router });

expect(res.unsatisfied).toEqual([]);
expect(res.contract.required).toHaveLength(2);
});

it('stays unsatisfied when the rebind covers only part of the contract', () => {
const router = new SchemaRouter({
identity: {
functions: { current_actor: { schema: null, name: 'current_user_id' } }
}
});
const res = excludeSubsystem(SQL, selector, { rebinds: router });

expect(res.unsatisfied.map(u => u.object.name)).toEqual(['users']);
});

it('accepts a whole-schema route as a resolution', () => {
const router = new SchemaRouter({ identity: { schema: 'replacement' } });
const res = excludeSubsystem(SQL, selector, { rebinds: router });
expect(res.unsatisfied).toEqual([]);
});

it('warns on opaque statements instead of guessing', () => {
const res = excludeSubsystem(
SQL + "\nCOMMENT ON TABLE identity.users IS 'internal';\n",
selector
);
expect(res.warnings.some(w => w.kind === 'opaque-statement')).toBe(true);
});

it('flags dynamic SQL in survivors', () => {
const res = excludeSubsystem(
SQL +
`\nCREATE FUNCTION app.sweep() RETURNS void AS $fn$
BEGIN
EXECUTE 'delete from ' || 'somewhere';
END;
$fn$ LANGUAGE plpgsql;\n`,
selector
);
expect(res.warnings.some(w => w.kind === 'dynamic-sql')).toBe(true);
});
});
250 changes: 250 additions & 0 deletions pgpm/slice/src/exclude.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import { classifyStatements, SchemaRouter, StatementFacts } from '@pgpmjs/transform';

import { SqlObjectRef } from './refs';

/**
* Selects a *subsystem*: the set of objects a module owns that a consumer
* wants to remove and substitute with another provider. Selection is by
* schema — every object created in one of these schemas belongs to the
* subsystem, as do the schemas themselves.
*/
export interface SubsystemSelector {
schemas: string[];
}

/**
* One reference the surviving statements make *into* the subsystem. This is
* the subsystem's external contract: whatever replaces it must satisfy every
* one of these (or the consumer must rewrite the referencing statement).
*/
export interface SubsystemDependency {
/** The subsystem object being depended on. */
object: SqlObjectRef;
/** True when at least one dependent is a foreign-key constraint. */
fk: boolean;
/** Statement indexes (into the classified statement list) that depend on it. */
dependents: number[];
}

/**
* The measured external contract of a subsystem: what the rest of the SQL
* actually requires of it, derived purely from the reference graph.
*/
export interface SubsystemContract {
/** Objects the subsystem creates. */
provides: SqlObjectRef[];
/** Subsystem objects referenced from outside — the replacement surface. */
required: SubsystemDependency[];
/** Subsystem objects nothing outside references — safe to drop silently. */
internal: SqlObjectRef[];
}

export interface ExcludeWarning {
/**
* - `mixed-statement`: a statement creates objects both inside and outside
* the subsystem, so it can be neither dropped nor kept cleanly.
* - `opaque-statement`: a statement's target is invisible to classification
* (e.g. `COMMENT ON`, bare `DROP`), so it is kept but may reference a
* dropped object.
* - `dynamic-sql`: a kept statement runs `EXECUTE`; references inside the
* dynamic string cannot be checked against the subsystem.
*/
kind: 'mixed-statement' | 'opaque-statement' | 'dynamic-sql';
statement: number;
detail: string;
}

/**
* A kept statement references a subsystem object that no route rebinds to a
* replacement. Exclusion is unsafe until every one of these is resolved.
*/
export interface UnsatisfiedReference {
object: SqlObjectRef;
statement: number;
fk: boolean;
}

export interface ExcludeResult {
/** Indexes of statements belonging to the subsystem (to be dropped). */
excluded: number[];
/** Indexes of surviving statements. */
kept: number[];
/** The subsystem's measured external contract. */
contract: SubsystemContract;
/**
* References into the subsystem from kept statements that the provided
* router does not rebind. Empty ⇔ the exclusion is safe.
*/
unsatisfied: UnsatisfiedReference[];
warnings: ExcludeWarning[];
/** The classified statements, for callers that need the facts. */
statements: StatementFacts[];
}

function inSubsystem(ref: SqlObjectRef, schemas: Set<string>): boolean {
return ref.schema !== null && schemas.has(ref.schema);
}

function refKey(ref: SqlObjectRef): string {
return `${ref.schema ?? ''}.${ref.name}`;
}

function pushUnique(list: SqlObjectRef[], ref: SqlObjectRef): void {
if (!list.some(x => x.schema === ref.schema && x.name === ref.name)) list.push(ref);
}

/**
* The classifier does not tag a reference with its namespace, so resolution
* tries each object namespace (FK targets are known to be relations) before
* falling back to the schema-level default.
*/
function resolveRebind(
router: SchemaRouter | undefined,
ref: SqlObjectRef,
fk: boolean
): boolean {
if (!router) return false;
const namespaces: Array<'relation' | 'function' | 'type' | 'unknown'> = fk
? ['relation']
: ['function', 'relation', 'type', 'unknown'];
for (const ns of namespaces) {
const target = router.resolveObject(ref.schema, ref.name, ns);
if (target !== undefined && (target.name !== undefined || target.schema !== undefined)) {
return true;
}
}
return false;
}

/**
* Decide statement membership: a statement belongs to the subsystem when
* everything it creates/targets is inside it, or (creating nothing) when all
* of its references point inside it (GRANTs on subsystem objects).
*/
function classifyMembership(
facts: StatementFacts,
schemas: Set<string>
): 'inside' | 'outside' | 'mixed' | 'opaque' {
if (facts.kind === 'schema') {
const created = facts.creates[0];
return created && schemas.has(created.name) ? 'inside' : 'outside';
}

if (facts.creates.length > 0) {
const inside = facts.creates.filter(c => inSubsystem(c, schemas)).length;
if (inside === facts.creates.length) return 'inside';
if (inside === 0) return 'outside';
return 'mixed';
}

const refs = [...facts.references, ...facts.bodyReferences];
if (refs.length > 0) {
return refs.every(r => inSubsystem(r, schemas)) ? 'inside' : 'outside';
}

// No creates, no visible references: COMMENT ON, bare DROP, SET, ...
return 'opaque';
}

/**
* Partition a SQL script into a subsystem (statements to exclude) and its
* survivors, measure the subsystem's external contract, and verify that a
* routing profile rebinds every surviving reference into it.
*
* Pure and I/O-free. The caller applies the actual removal/rewrite (e.g. via
* `transpileBundle`'s `transformScript` with the same router); this function
* only *decides* and *checks* — exclusion is safe iff `unsatisfied` is empty.
*/
export function excludeSubsystem(
sql: string,
selector: SubsystemSelector,
options: { rebinds?: SchemaRouter } = {}
): ExcludeResult {
const schemas = new Set(selector.schemas);
const statements = classifyStatements(sql);
const router = options.rebinds;

const excluded: number[] = [];
const kept: number[] = [];
const warnings: ExcludeWarning[] = [];
const provides: SqlObjectRef[] = [];
const internal: SqlObjectRef[] = [];
const required = new Map<string, SubsystemDependency>();
const unsatisfied: UnsatisfiedReference[] = [];

statements.forEach((facts, i) => {
const membership = classifyMembership(facts, schemas);

if (membership === 'inside') {
excluded.push(i);
for (const c of facts.creates) {
if (inSubsystem(c, schemas)) pushUnique(provides, { schema: c.schema, name: c.name });
}
return;
}

kept.push(i);

if (membership === 'mixed') {
warnings.push({
kind: 'mixed-statement',
statement: i,
detail: `creates objects both inside and outside the subsystem: ${facts.creates
.map(refKey)
.join(', ')}`
});
} else if (membership === 'opaque') {
warnings.push({
kind: 'opaque-statement',
statement: i,
detail: `${facts.nodeTag} target is not classified; verify it does not reference the subsystem`
});
}

if (facts.dynamicSql) {
warnings.push({
kind: 'dynamic-sql',
statement: i,
detail: 'kept statement executes dynamic SQL; references inside it are unchecked'
});
}

const refs = new Map<string, { ref: SqlObjectRef; fk: boolean }>();
for (const r of [...facts.references, ...facts.bodyReferences]) {
if (inSubsystem(r, schemas)) refs.set(refKey(r), { ref: r, fk: refs.get(refKey(r))?.fk ?? false });
}
for (const t of facts.fkTargets) {
if (inSubsystem(t, schemas)) refs.set(refKey(t), { ref: t, fk: true });
}

for (const { ref, fk } of refs.values()) {
const key = refKey(ref);
const dep = required.get(key) ?? { object: { schema: ref.schema, name: ref.name }, fk: false, dependents: [] };
dep.fk = dep.fk || fk;
if (!dep.dependents.includes(i)) dep.dependents.push(i);
required.set(key, dep);

const rebound = resolveRebind(router, ref, fk);
if (!rebound && !unsatisfied.some(u => refKey(u.object) === key && u.statement === i)) {
unsatisfied.push({ object: { schema: ref.schema, name: ref.name }, statement: i, fk });
}
}
});

for (const p of provides) {
if (!required.has(refKey(p))) pushUnique(internal, p);
}

return {
excluded,
kept,
contract: {
provides,
required: [...required.values()],
internal
},
unsatisfied,
warnings,
statements
};
}
1 change: 1 addition & 0 deletions pgpm/slice/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './output';
export * from './refs';
export * from './closure';
export * from './partition';
export * from './exclude';
2 changes: 1 addition & 1 deletion pgpm/transform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"makage": "^0.3.0"
},
"dependencies": {
"@pgsql/transform": "^18.6.0",
"@pgsql/transform": "^18.7.0",
"plpgsql-parser": "^18.2.1"
}
}
Loading
Loading