Skip to content

Commit c8c78bd

Browse files
committed
refactor(slice): converge on @pgsql/transform's graph substrate
extractSqlFacts is now a thin adapter over classifyStatements + buildStatementGraph's producer index; the object graph adopts the upstream hard|fk|late edge taxonomy and takes intra-program edges straight from buildStatementGraph.
1 parent f1b54cd commit c8c78bd

5 files changed

Lines changed: 82 additions & 46 deletions

File tree

pgpm/slice/__tests__/object-graph.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ describe('buildObjectGraph', () => {
3333
const intoUsers = graph.incoming.get(objectKey({ schema: 'auth', name: 'users' }))!;
3434
const kinds = intoUsers.map(e => `${e.from.program}:${e.kind}`).sort();
3535
expect(kinds).toContain('app/posts:fk');
36-
expect(kinds).toContain('auth/uid:body');
36+
expect(kinds).toContain('auth/uid:late');
3737
});
3838
});
3939

@@ -58,7 +58,7 @@ describe('danglingEdges', () => {
5858
const summary = dangling.map(e => `${e.from.program}:${e.kind}`).sort();
5959
// auth/uid's body reference to auth.users originates inside the dropped
6060
// set, so it must NOT dangle; the FK and the policy accessor call must.
61-
expect(summary).toEqual(['app/policy:reference', 'app/posts:fk', 'app/posts:reference']);
61+
expect(summary).toEqual(['app/policy:hard', 'app/posts:fk']);
6262
});
6363

6464
it('is empty when the referencing statements are dropped too', () => {

pgpm/slice/src/closure.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync, readFileSync } from 'fs';
22
import { join } from 'path';
33

4+
import { objectKey } from './object-graph';
45
import { extractSqlFacts } from './refs';
56
import {
67
ClosureAutoInclude,
@@ -9,10 +10,6 @@ import {
910
PatternStrategy
1011
} from './types';
1112

12-
function objectKey(schema: string | null, name: string): string {
13-
return `${schema ?? ''}\u0000${name}`;
14-
}
15-
1613
function refLabel(schema: string | null, name: string): string {
1714
return schema ? `${schema}.${name}` : name;
1815
}
@@ -51,7 +48,7 @@ export function buildAstEdges(graph: DependencyGraph, moduleDir: string): AstEdg
5148
const f = facts.get(change.name);
5249
if (!f) continue;
5350
for (const c of f.creates) {
54-
const key = objectKey(c.schema, c.name);
51+
const key = objectKey(c);
5552
if (!producerByObject.has(key)) producerByObject.set(key, change.name);
5653
}
5754
}
@@ -63,7 +60,7 @@ export function buildAstEdges(graph: DependencyGraph, moduleDir: string): AstEdg
6360
for (const [changeName, f] of facts) {
6461
if (f.dynamicSql) dynamicSqlChanges.push(changeName);
6562
for (const r of f.references) {
66-
const producer = producerByObject.get(objectKey(r.schema, r.name));
63+
const producer = producerByObject.get(objectKey(r));
6764
if (!producer) {
6865
unresolvedReferences.push({ change: changeName, ref: refLabel(r.schema, r.name) });
6966
continue;

pgpm/slice/src/object-graph.ts

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
* Unified SQL object graph — the semantic IR over parsed programs.
33
*
44
* Nodes are database objects (keyed by qualified name) with the statements
5-
* that create them; edges are the references statements make to objects:
6-
* plain references, late-bound PL/pgSQL body references, and foreign-key
7-
* targets. Programs are named (typically one per pgpm change), so ownership
8-
* queries answer change-level questions directly:
5+
* that create them; edges are the references statements make to objects,
6+
* typed with `@pgsql/transform`'s edge taxonomy (`hard | fk | late`).
7+
* Programs are named (typically one per pgpm change), so ownership queries
8+
* answer change-level questions directly:
99
*
1010
* - which objects live in a schema set (subsystem selection)
1111
* - which surviving statements still reach into a dropped object set
@@ -14,9 +14,15 @@
1414
* - which programs create nothing outside a dropped set (whole-change
1515
* pruning by ownership, not survivor counting)
1616
*
17+
* Each program's intra-program edges come straight from
18+
* `buildStatementGraph`; references with no in-program producer (external
19+
* dependencies, which the statement graph deliberately omits) are added with
20+
* the same kind classification, so the object graph is a superset projection
21+
* of the statement graph over the same facts.
22+
*
1723
* Pure and I/O-free; built from `SqlProgram`s parsed once elsewhere.
1824
*/
19-
import type { SqlProgram, StatementFacts } from '@pgpmjs/transform';
25+
import { buildStatementGraph, EdgeKind, SqlProgram, StatementFacts } from '@pgpmjs/transform';
2026

2127
import { SqlObjectRef } from './refs';
2228

@@ -26,7 +32,13 @@ export interface StatementId {
2632
statement: number;
2733
}
2834

29-
export type ObjectEdgeKind = 'reference' | 'body' | 'fk';
35+
/**
36+
* How an edge constrains ordering — `@pgsql/transform`'s taxonomy:
37+
* `hard` (must exist at CREATE time), `fk` (a hard edge from a foreign-key
38+
* target), `late` (reached only inside a PL/pgSQL body, resolved at call
39+
* time).
40+
*/
41+
export type ObjectEdgeKind = EdgeKind;
3042

3143
/** One reference from a statement to an object. */
3244
export interface ObjectEdge {
@@ -78,29 +90,60 @@ export function buildObjectGraph(
7890
const incoming = new Map<string, ObjectEdge[]>();
7991

8092
const addEdge = (edge: ObjectEdge): void => {
81-
edges.push(edge);
8293
const list = incoming.get(edge.to);
83-
if (list) list.push(edge);
84-
else incoming.set(edge.to, [edge]);
94+
if (list) {
95+
if (list.some(
96+
e => e.kind === edge.kind &&
97+
e.from.program === edge.from.program &&
98+
e.from.statement === edge.from.statement
99+
)) return;
100+
list.push(edge);
101+
} else {
102+
incoming.set(edge.to, [edge]);
103+
}
104+
edges.push(edge);
85105
};
86106

87107
for (const [name, program] of programMap) {
88-
program.statements.forEach((stmt, i) => {
108+
const facts = program.statements.map(s => s.facts);
109+
const statementGraph = buildStatementGraph(facts);
110+
111+
facts.forEach((stmt, i) => {
89112
const id: StatementId = { program: name, statement: i };
90-
for (const c of stmt.facts.creates) {
113+
for (const c of stmt.creates) {
91114
const key = objectKey(c);
92115
const node = objects.get(key);
93116
if (node) node.createdBy.push(id);
94117
else objects.set(key, { ref: { schema: c.schema, name: c.name }, createdBy: [id] });
95118
}
96-
for (const r of stmt.facts.references) {
97-
addEdge({ from: id, to: objectKey(r), kind: 'reference' });
98-
}
99-
for (const r of stmt.facts.bodyReferences) {
100-
addEdge({ from: id, to: objectKey(r), kind: 'body' });
119+
});
120+
121+
// Intra-program edges, exactly as the statement graph typed them.
122+
for (const e of statementGraph.edges) {
123+
addEdge({ from: { program: name, statement: e.from }, to: objectKey(e.via), kind: e.kind });
124+
}
125+
126+
// References with no in-program producer are external dependencies: the
127+
// statement graph induces no edge for them, but the object graph keeps
128+
// them (that is what contract/cascade queries measure). Same kind
129+
// classification: fk beats late beats hard.
130+
facts.forEach((stmt, i) => {
131+
const id: StatementId = { program: name, statement: i };
132+
const bodyOnly = new Set(stmt.bodyReferences.map(objectKey));
133+
const fkKeys = new Set(stmt.fkTargets.map(objectKey));
134+
const seen = new Set<string>();
135+
for (const r of stmt.references) {
136+
const key = objectKey(r);
137+
seen.add(key);
138+
if (statementGraph.producers.has(`${r.schema ?? ''}.${r.name}`)) continue;
139+
const kind: EdgeKind = fkKeys.has(key) ? 'fk' : bodyOnly.has(key) ? 'late' : 'hard';
140+
addEdge({ from: id, to: key, kind });
101141
}
102-
for (const t of stmt.facts.fkTargets) {
103-
addEdge({ from: id, to: objectKey(t), kind: 'fk' });
142+
for (const t of stmt.fkTargets) {
143+
const key = objectKey(t);
144+
if (seen.has(key)) continue;
145+
if (statementGraph.producers.has(`${t.schema ?? ''}.${t.name}`)) continue;
146+
addEdge({ from: id, to: key, kind: 'fk' });
104147
}
105148
});
106149
}

pgpm/slice/src/partition.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { join } from 'path';
44
import { parsePlanFile } from '@pgpmjs/ast/files/plan/parser';
55

66
import { AstEdges, buildAstEdges } from './closure';
7+
import { objectKey } from './object-graph';
78
import { extractSqlFacts, SqlObjectRef } from './refs';
89
import { buildDependencyGraph } from './slice';
910
import { DependencyGraph } from './types';
@@ -150,10 +151,6 @@ export interface PartitionModuleResult extends PartitionResult {
150151
seedChanges: string[];
151152
}
152153

153-
function objectKey(schema: string | null, name: string): string {
154-
return `${schema ?? ''}\u0000${name}`;
155-
}
156-
157154
/**
158155
* On-disk entry point: parse a module's plan + deploy SQL, resolve the given
159156
* per-tenant seed *objects* to the changes that create them, and partition the
@@ -178,15 +175,15 @@ export function partitionModule(options: PartitionModuleOptions): PartitionModul
178175
if (!existsSync(deployPath)) continue;
179176
const facts = extractSqlFacts(readFileSync(deployPath, 'utf-8'));
180177
for (const c of facts.creates) {
181-
const key = objectKey(c.schema, c.name);
178+
const key = objectKey(c);
182179
if (!producerByObject.has(key)) producerByObject.set(key, change.name);
183180
}
184181
}
185182

186183
const seedChanges: string[] = [];
187184
const unresolvedSeeds: PartitionWarning[] = [];
188185
for (const obj of options.seedObjects) {
189-
const producer = producerByObject.get(objectKey(obj.schema, obj.name));
186+
const producer = producerByObject.get(objectKey(obj));
190187
if (!producer) {
191188
unresolvedSeeds.push({
192189
kind: 'unknown-seed',

pgpm/slice/src/refs.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { classifyStatements } from '@pgpmjs/transform';
1+
import { buildStatementGraph, classifyStatements, QualifiedName } from '@pgpmjs/transform';
22

33
/**
44
* The parser runs on a WASM build of the real PostgreSQL parser; callers must
@@ -8,11 +8,10 @@ export { loadModule } from 'plpgsql-parser';
88

99
/**
1010
* A (possibly schema-qualified) database object name extracted from SQL.
11+
* Alias of `@pgsql/transform`'s `QualifiedName` — one name type across the
12+
* facts substrate and the slice layer.
1113
*/
12-
export interface SqlObjectRef {
13-
schema: string | null;
14-
name: string;
15-
}
14+
export type SqlObjectRef = QualifiedName;
1615

1716
/**
1817
* AST-derived facts about a change's deploy SQL, used to discover
@@ -46,27 +45,27 @@ function pushUnique(list: SqlObjectRef[], r: SqlObjectRef): void {
4645
* including references reached inside PL/pgSQL function bodies (which are
4746
* opaque strings in `CREATE FUNCTION` and invisible to a plain SQL parse).
4847
*
49-
* Aggregates `@pgsql/transform`'s per-statement `classifyStatements` facts to
50-
* the change level: references satisfied by objects the same change creates
51-
* (in any statement) are dropped.
48+
* A thin change-level adapter over `@pgsql/transform`: `classifyStatements`
49+
* produces the per-statement facts and `buildStatementGraph`'s producer index
50+
* decides which references are internal — a reference with an in-script
51+
* producer is satisfied by the change itself, so only external references
52+
* (the statement graph's non-edges) surface as change-level dependencies.
5253
*/
5354
export function extractSqlFacts(sql: string): ChangeSqlFacts {
55+
const statements = classifyStatements(sql);
56+
const graph = buildStatementGraph(statements);
5457
const facts: ChangeSqlFacts = { creates: [], references: [], dynamicSql: false };
5558

56-
for (const stmt of classifyStatements(sql)) {
59+
for (const stmt of statements) {
5760
for (const c of stmt.creates) {
5861
pushUnique(facts.creates, { schema: c.schema, name: c.name });
5962
}
6063
for (const r of stmt.references) {
64+
if (graph.producers.has(`${r.schema ?? ''}.${r.name}`)) continue;
6165
pushUnique(facts.references, { schema: r.schema, name: r.name });
6266
}
6367
if (stmt.dynamicSql) facts.dynamicSql = true;
6468
}
6569

66-
// A change does not depend on objects it creates itself.
67-
facts.references = facts.references.filter(
68-
r => !facts.creates.some(c => c.schema === r.schema && c.name === r.name)
69-
);
70-
7170
return facts;
7271
}

0 commit comments

Comments
 (0)