@homeflare/alchemy 0.46.0 → 0.46.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/postgres/connection.d.ts.map +1 -1
- package/dist/postgres/fake-schema-drop.d.ts +13 -0
- package/dist/postgres/fake-schema-drop.d.ts.map +1 -0
- package/dist/postgres/fake-schema-sql.d.ts +5 -0
- package/dist/postgres/fake-schema-sql.d.ts.map +1 -1
- package/dist/postgres/fake-sql.d.ts +4 -0
- package/dist/postgres/fake-sql.d.ts.map +1 -1
- package/dist/postgres/index.d.ts +2 -1
- package/dist/postgres/index.d.ts.map +1 -1
- package/dist/postgres/index.js +461 -278
- package/dist/postgres/index.js.map +19 -15
- package/dist/postgres/psql-executor.d.ts.map +1 -1
- package/dist/postgres/schema-closure-sql.d.ts +45 -0
- package/dist/postgres/schema-closure-sql.d.ts.map +1 -0
- package/dist/postgres/schema-delete.d.ts +17 -8
- package/dist/postgres/schema-delete.d.ts.map +1 -1
- package/dist/postgres/schema-drop-sql.d.ts +66 -0
- package/dist/postgres/schema-drop-sql.d.ts.map +1 -0
- package/dist/postgres/schema-errors.d.ts +19 -1
- package/dist/postgres/schema-errors.d.ts.map +1 -1
- package/dist/postgres/schema-sequence-error.d.ts +29 -0
- package/dist/postgres/schema-sequence-error.d.ts.map +1 -0
- package/dist/postgres/schema-sql.d.ts +16 -0
- package/dist/postgres/schema-sql.d.ts.map +1 -1
- package/dist/postgres/schema-test-kit.d.ts +3 -1
- package/dist/postgres/schema-test-kit.d.ts.map +1 -1
- package/dist/postgres/search-path.d.ts +38 -0
- package/dist/postgres/search-path.d.ts.map +1 -0
- package/docs/postgres-schema.md +22 -1
- package/package.json +1 -1
- package/src/postgres/connection.ts +10 -14
- package/src/postgres/database-bigint-serialization.test.ts +2 -2
- package/src/postgres/database-sql.ts +6 -6
- package/src/postgres/fake-grants-read.ts +3 -3
- package/src/postgres/fake-role-sql.ts +2 -2
- package/src/postgres/fake-schema-drop.ts +75 -0
- package/src/postgres/fake-schema-sql.ts +10 -1
- package/src/postgres/fake-sql.ts +10 -3
- package/src/postgres/grants-database.test.ts +5 -5
- package/src/postgres/grants-existence.ts +7 -7
- package/src/postgres/grants-ownership.ts +5 -5
- package/src/postgres/grants-psql-lifecycle.test.ts +7 -3
- package/src/postgres/grants-read-defaults.ts +5 -5
- package/src/postgres/grants-read.ts +16 -16
- package/src/postgres/index.ts +2 -0
- package/src/postgres/psql-executor-escaping.test.ts +3 -1
- package/src/postgres/psql-executor.test.ts +11 -5
- package/src/postgres/psql-executor.ts +4 -1
- package/src/postgres/role-adopt.test.ts +3 -1
- package/src/postgres/role-membership-options.test.ts +3 -1
- package/src/postgres/role-membership-repair.test.ts +3 -3
- package/src/postgres/role-membership-sql.ts +4 -4
- package/src/postgres/role-password-transports.test.ts +4 -2
- package/src/postgres/role-sql.ts +1 -1
- package/src/postgres/schema-closure-sql.ts +108 -0
- package/src/postgres/schema-delete-proofs.test.ts +7 -7
- package/src/postgres/schema-delete.ts +88 -46
- package/src/postgres/schema-drop-2bp01.test.ts +29 -17
- package/src/postgres/schema-drop-real.test.ts +190 -0
- package/src/postgres/schema-drop-sql.ts +135 -0
- package/src/postgres/schema-drop.test.ts +241 -0
- package/src/postgres/schema-errors.ts +26 -0
- package/src/postgres/schema-handlers.test.ts +21 -14
- package/src/postgres/schema-lifecycle.test.ts +6 -60
- package/src/postgres/schema-sequence-error.ts +38 -0
- package/src/postgres/schema-socket.test.ts +20 -6
- package/src/postgres/schema-sql.ts +16 -11
- package/src/postgres/schema-takeover.test.ts +7 -4
- package/src/postgres/schema-test-kit.ts +18 -4
- package/src/postgres/search-path.test.ts +100 -0
- package/src/postgres/search-path.ts +62 -0
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as Effect from 'effect/Effect';
|
|
12
12
|
import { SqlError, SqlSyntaxError, UnknownError } from 'effect/unstable/sql/SqlError';
|
|
13
|
+
import { applyAtomicDrop } from './fake-schema-drop.ts';
|
|
13
14
|
import { parseCommentSchema, parseCreateSchema, parseDropSchema } from './fake-sql-parse.ts';
|
|
14
15
|
import type { PostgresSchemaAttributes } from './schema-attrs.ts';
|
|
15
16
|
|
|
@@ -18,6 +19,11 @@ export interface FakeSchemaState {
|
|
|
18
19
|
readonly schemas: Map<string, PostgresSchemaAttributes>;
|
|
19
20
|
/** Names of schemas this fake pretends hold at least one relation. */
|
|
20
21
|
readonly relationsIn: Set<string>;
|
|
22
|
+
/** Per schema name: how many objects in OTHER schemas depend on it (what the atomic drop's
|
|
23
|
+
* `pg_depend` count answers). Absent means none. */
|
|
24
|
+
readonly dependentsOutside: Map<string, number>;
|
|
25
|
+
/** Per schema name: how many sequences it holds (a cascade drop refuses any: `HF004`). */
|
|
26
|
+
readonly sequencesIn: Map<string, number>;
|
|
21
27
|
/** What `current_database()` answers, and the stamp on every read row. */
|
|
22
28
|
readonly database: string;
|
|
23
29
|
/** What `current_user` answers, and the owner of a `CREATE SCHEMA` without `AUTHORIZATION`. */
|
|
@@ -44,6 +50,9 @@ export const applySchemaStatement = <A extends object>(
|
|
|
44
50
|
text: string,
|
|
45
51
|
params: ReadonlyArray<unknown>,
|
|
46
52
|
): Effect.Effect<ReadonlyArray<A>, SqlError> | undefined => {
|
|
53
|
+
// The atomic drop's text contains every marker below; matched first, by its own prefix.
|
|
54
|
+
if (text.startsWith('DO ')) return applyAtomicDrop(state, text);
|
|
55
|
+
|
|
47
56
|
if (text.startsWith('SELECT current_database()')) {
|
|
48
57
|
return Effect.succeed([{ database: state.database }] as unknown as ReadonlyArray<A>);
|
|
49
58
|
}
|
|
@@ -57,7 +66,7 @@ export const applySchemaStatement = <A extends object>(
|
|
|
57
66
|
return Effect.succeed([{ empty: !state.relationsIn.has(name) }] as unknown as ReadonlyArray<A>);
|
|
58
67
|
}
|
|
59
68
|
|
|
60
|
-
if (text.includes('FROM pg_namespace')) {
|
|
69
|
+
if (text.includes('FROM pg_catalog.pg_namespace')) {
|
|
61
70
|
const name = params[0] as string;
|
|
62
71
|
const row = state.schemas.get(name);
|
|
63
72
|
return Effect.succeed(
|
package/src/postgres/fake-sql.ts
CHANGED
|
@@ -65,6 +65,10 @@ export interface FakeSqlOptions {
|
|
|
65
65
|
readonly schemas?: ReadonlyArray<PostgresSchemaAttributes>;
|
|
66
66
|
/** Names of schemas the fake answers `schemaIsEmpty` with `false` for. */
|
|
67
67
|
readonly schemasWithRelations?: ReadonlyArray<string>;
|
|
68
|
+
/** Per schema name, how many objects in OTHER schemas depend on it (a cascade would drop
|
|
69
|
+
* them): what the atomic drop's `pg_depend` count answers. */
|
|
70
|
+
readonly dependentsOutside?: Readonly<Record<string, number>>;
|
|
71
|
+
readonly sequencesIn?: Readonly<Record<string, number>>; // per schema: sequences held (HF004)
|
|
68
72
|
/** Accept the NEXT `CREATE SCHEMA` (no error) but record nothing — the S10 case where the
|
|
69
73
|
* write's own report is a lie and the immediate re-read finds nothing. */
|
|
70
74
|
readonly swallowNextCreateSchema?: boolean;
|
|
@@ -93,6 +97,7 @@ export const makeFakeSql = (options: FakeSqlOptions = {}): FakeSql => {
|
|
|
93
97
|
>();
|
|
94
98
|
const schemas = seedSchemas(options.schemas, options.database ?? 'postgres');
|
|
95
99
|
const relationsIn = new Set(options.schemasWithRelations ?? []);
|
|
100
|
+
const dependentsOutside = new Map(Object.entries(options.dependentsOutside ?? {}));
|
|
96
101
|
let raceRemaining = options.raceNextCreate === true ? 1 : 0;
|
|
97
102
|
let failNext = options.failNext;
|
|
98
103
|
let oidCounter = 20000;
|
|
@@ -111,6 +116,8 @@ export const makeFakeSql = (options: FakeSqlOptions = {}): FakeSql => {
|
|
|
111
116
|
const schemaState: FakeSchemaState = {
|
|
112
117
|
schemas,
|
|
113
118
|
relationsIn,
|
|
119
|
+
dependentsOutside,
|
|
120
|
+
sequencesIn: new Map(Object.entries(options.sequencesIn ?? {})),
|
|
114
121
|
database: options.database ?? 'postgres',
|
|
115
122
|
standardConformingStrings: options.standardConformingStrings ?? true,
|
|
116
123
|
sessionRole: options.currentUser ?? 'postgres',
|
|
@@ -139,7 +146,7 @@ export const makeFakeSql = (options: FakeSqlOptions = {}): FakeSql => {
|
|
|
139
146
|
);
|
|
140
147
|
}
|
|
141
148
|
|
|
142
|
-
if (text.startsWith('SELECT 1 AS present FROM pg_roles')) {
|
|
149
|
+
if (text.startsWith('SELECT 1 AS present FROM pg_catalog.pg_roles')) {
|
|
143
150
|
const role = params[0] as string;
|
|
144
151
|
return Effect.succeed(
|
|
145
152
|
(roleNames.has(role) || roleRows.has(role)
|
|
@@ -151,14 +158,14 @@ export const makeFakeSql = (options: FakeSqlOptions = {}): FakeSql => {
|
|
|
151
158
|
// ⚠️ startsWith, BEFORE the `FROM pg_database` branch: the full-row select also contains
|
|
152
159
|
// `FROM pg_database`, but starts with `SELECT d.oid` — only the existence probe starts
|
|
153
160
|
// with `SELECT 1 AS present`.
|
|
154
|
-
if (text.startsWith('SELECT 1 AS present FROM pg_database')) {
|
|
161
|
+
if (text.startsWith('SELECT 1 AS present FROM pg_catalog.pg_database')) {
|
|
155
162
|
const name = params[0] as string;
|
|
156
163
|
return Effect.succeed(
|
|
157
164
|
(databases.has(name) ? [{ present: 1 }] : []) as unknown as ReadonlyArray<A>,
|
|
158
165
|
);
|
|
159
166
|
}
|
|
160
167
|
|
|
161
|
-
if (text.includes('FROM pg_database')) {
|
|
168
|
+
if (text.includes('FROM pg_catalog.pg_database')) {
|
|
162
169
|
const name = params[0] as string;
|
|
163
170
|
const row = databases.get(name);
|
|
164
171
|
return Effect.succeed((row === undefined ? [] : [row]) as unknown as ReadonlyArray<A>);
|
|
@@ -48,7 +48,7 @@ const recording = (probe: string, proof: string) => {
|
|
|
48
48
|
const run: PsqlRunner = ({ stdin, argv }) => {
|
|
49
49
|
stdins.push(stdin);
|
|
50
50
|
argvs.push(argv);
|
|
51
|
-
const stdout = stdin.includes('FROM pg_database')
|
|
51
|
+
const stdout = stdin.includes('FROM pg_catalog.pg_database')
|
|
52
52
|
? probe
|
|
53
53
|
: stdin.includes('current_database()')
|
|
54
54
|
? proof
|
|
@@ -79,7 +79,7 @@ describe('a missing declared database is absent', () => {
|
|
|
79
79
|
// current_database()/GRANT/REVOKE ever targeted the (absent) declared database.
|
|
80
80
|
expect(result).toBeUndefined();
|
|
81
81
|
expect(stdins.length).toBe(1);
|
|
82
|
-
expect(stdins[0]).toContain('FROM pg_database');
|
|
82
|
+
expect(stdins[0]).toContain('FROM pg_catalog.pg_database');
|
|
83
83
|
expect(stdins[0]).not.toContain('current_database()');
|
|
84
84
|
expect(argvs[0]?.at(-1)).toBe('postgres');
|
|
85
85
|
});
|
|
@@ -106,7 +106,7 @@ describe('a present database is opened and proved', () => {
|
|
|
106
106
|
.pipe(Effect.provide(provide(run))),
|
|
107
107
|
);
|
|
108
108
|
expect(argvs[0]?.at(-1)).toBe('postgres');
|
|
109
|
-
expect(stdins[0]).toContain('FROM pg_database');
|
|
109
|
+
expect(stdins[0]).toContain('FROM pg_catalog.pg_database');
|
|
110
110
|
expect(argvs.slice(1).every((argv) => argv.at(-1) === 'agents')).toBe(true);
|
|
111
111
|
expect(stdins[1]).toContain('current_database()');
|
|
112
112
|
});
|
|
@@ -129,7 +129,7 @@ describe('a present database is opened and proved', () => {
|
|
|
129
129
|
// declared database and proves current_database() there.
|
|
130
130
|
expect(argvs.length).toBe(2);
|
|
131
131
|
expect(argvs[0]?.at(-1)).toBe('postgres');
|
|
132
|
-
expect(stdins[0]).toContain('FROM pg_database');
|
|
132
|
+
expect(stdins[0]).toContain('FROM pg_catalog.pg_database');
|
|
133
133
|
expect(argvs[1]?.at(-1)).toBe('agents');
|
|
134
134
|
expect(stdins[1]).toContain('current_database()');
|
|
135
135
|
});
|
|
@@ -152,7 +152,7 @@ describe('a missing declared database on reconcile is a typed refusal', () => {
|
|
|
152
152
|
expect(error).toMatchObject({ database: 'agents' });
|
|
153
153
|
// Only the family-connection probe ran; reconcile never opened the declared database.
|
|
154
154
|
expect(stdins.length).toBe(1);
|
|
155
|
-
expect(stdins[0]).toContain('FROM pg_database');
|
|
155
|
+
expect(stdins[0]).toContain('FROM pg_catalog.pg_database');
|
|
156
156
|
expect(argvs[0]?.at(-1)).toBe('postgres');
|
|
157
157
|
});
|
|
158
158
|
});
|
|
@@ -15,9 +15,9 @@ import { PostgresGrantsColumnMissing, PostgresGrantsTableMissing } from './grant
|
|
|
15
15
|
* (`func.sgml@REL_18_6`). */
|
|
16
16
|
const TABLES_PRESENT_SQL = `SELECT
|
|
17
17
|
c.relname AS table
|
|
18
|
-
FROM pg_class c
|
|
19
|
-
WHERE c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1)
|
|
20
|
-
AND c.relname::text IN (SELECT json_array_elements_text($2::json))`;
|
|
18
|
+
FROM pg_catalog.pg_class c
|
|
19
|
+
WHERE c.relnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1)
|
|
20
|
+
AND c.relname::text IN (SELECT pg_catalog.json_array_elements_text($2::json))`;
|
|
21
21
|
|
|
22
22
|
const findMissingTable = (
|
|
23
23
|
pg: PgExecutor,
|
|
@@ -41,13 +41,13 @@ const findMissingTable = (
|
|
|
41
41
|
const COLUMNS_PRESENT_SQL = `SELECT
|
|
42
42
|
c.relname AS table,
|
|
43
43
|
a.attname AS column
|
|
44
|
-
FROM pg_attribute a
|
|
45
|
-
JOIN pg_class c ON c.oid = a.attrelid
|
|
46
|
-
WHERE c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1)
|
|
44
|
+
FROM pg_catalog.pg_attribute a
|
|
45
|
+
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
|
|
46
|
+
WHERE c.relnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1)
|
|
47
47
|
AND a.attnum > 0
|
|
48
48
|
AND NOT a.attisdropped
|
|
49
49
|
AND (c.relname, a.attname) IN (
|
|
50
|
-
SELECT x->>0, x->>1 FROM json_array_elements($2::json) x
|
|
50
|
+
SELECT x->>0, x->>1 FROM pg_catalog.json_array_elements($2::json) x
|
|
51
51
|
)`;
|
|
52
52
|
|
|
53
53
|
const findMissingColumn = (
|
|
@@ -17,8 +17,8 @@ import type { PgExecutor } from './database-sql.ts';
|
|
|
17
17
|
/** `pg_namespace.nspowner = <role>` for one schema. No rows when the schema is absent —
|
|
18
18
|
* the caller then reads the fact as `false`. */
|
|
19
19
|
const SCHEMA_OWNER_SQL = `SELECT
|
|
20
|
-
n.nspowner = (SELECT oid FROM pg_roles WHERE rolname = $2) AS role_owns
|
|
21
|
-
FROM pg_namespace n
|
|
20
|
+
n.nspowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $2) AS role_owns
|
|
21
|
+
FROM pg_catalog.pg_namespace n
|
|
22
22
|
WHERE n.nspname = $1`;
|
|
23
23
|
|
|
24
24
|
export const readSchemaOwnership = (
|
|
@@ -34,9 +34,9 @@ export const readSchemaOwnership = (
|
|
|
34
34
|
/** Every relation in the schema the declared role OWNS, all relkinds (the table class is
|
|
35
35
|
* not relkind-filtered, and neither is this). */
|
|
36
36
|
const OWNED_TABLES_SQL = `SELECT c.relname AS table
|
|
37
|
-
FROM pg_class c
|
|
38
|
-
WHERE c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1)
|
|
39
|
-
AND c.relowner = (SELECT oid FROM pg_roles WHERE rolname = $2)
|
|
37
|
+
FROM pg_catalog.pg_class c
|
|
38
|
+
WHERE c.relnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1)
|
|
39
|
+
AND c.relowner = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $2)
|
|
40
40
|
ORDER BY c.relname`;
|
|
41
41
|
|
|
42
42
|
export const readOwnedTables = (
|
|
@@ -8,6 +8,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
8
8
|
import * as Effect from 'effect/Effect';
|
|
9
9
|
import type { PsqlRunner } from './psql-executor.ts';
|
|
10
10
|
import { makePsqlExecutor } from './psql-executor.ts';
|
|
11
|
+
import { stripPin } from './search-path.ts';
|
|
11
12
|
import { reconcileWithClient } from './grants-ops.ts';
|
|
12
13
|
import { requireDeclaredObjectsExist } from './grants-existence.ts';
|
|
13
14
|
import { resolveProps } from './grants-declare.ts';
|
|
@@ -26,7 +27,8 @@ const props: PostgresGrantsProps = {
|
|
|
26
27
|
const runner = (): { run: PsqlRunner; stdins: string[] } => {
|
|
27
28
|
const stdins: string[] = [];
|
|
28
29
|
let granted = false;
|
|
29
|
-
const run: PsqlRunner = ({ stdin }) => {
|
|
30
|
+
const run: PsqlRunner = ({ stdin: raw }) => {
|
|
31
|
+
const stdin = stripPin(raw);
|
|
30
32
|
stdins.push(stdin);
|
|
31
33
|
if (stdin.startsWith('BEGIN')) granted = true;
|
|
32
34
|
return Promise.resolve({ code: 0, stdout: answer(stdin, granted), stderr: '' });
|
|
@@ -40,8 +42,10 @@ const answer = (stdin: string, granted: boolean): string => {
|
|
|
40
42
|
if (stdin.includes('json_array_elements')) {
|
|
41
43
|
return '[{"table":"a","column":"b.c"},{"table":"a.b","column":"c"}]';
|
|
42
44
|
}
|
|
43
|
-
if (stdin.includes('SELECT 1 AS present FROM pg_roles')) return '[{"present":1}]';
|
|
44
|
-
if (stdin.includes('SELECT 1 AS present FROM pg_namespace'))
|
|
45
|
+
if (stdin.includes('SELECT 1 AS present FROM pg_catalog.pg_roles')) return '[{"present":1}]';
|
|
46
|
+
if (stdin.includes('SELECT 1 AS present FROM pg_catalog.pg_namespace')) {
|
|
47
|
+
return '[{"present":1}]';
|
|
48
|
+
}
|
|
45
49
|
if (stdin.includes('aclexplode(n.nspacl)')) {
|
|
46
50
|
return granted ? '[{"public":false,"privilege":"usage","grantable":false}]' : '[]';
|
|
47
51
|
}
|
|
@@ -15,14 +15,14 @@ import { type AclRow, encodeWord } from './grants-words.ts';
|
|
|
15
15
|
* role's words, whatever role created the entry. Sorted by forRole so two reads of the
|
|
16
16
|
* same state compare equal. */
|
|
17
17
|
const DEFAULTS_SQL = `SELECT
|
|
18
|
-
pg_get_userbyid(d.defaclrole) AS for_role,
|
|
18
|
+
pg_catalog.pg_get_userbyid(d.defaclrole) AS for_role,
|
|
19
19
|
a.privilege_type AS privilege,
|
|
20
20
|
a.is_grantable AS grantable
|
|
21
|
-
FROM pg_default_acl d
|
|
22
|
-
CROSS JOIN LATERAL aclexplode(d.defaclacl) AS a
|
|
23
|
-
WHERE d.defaclnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1)
|
|
21
|
+
FROM pg_catalog.pg_default_acl d
|
|
22
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(d.defaclacl) AS a
|
|
23
|
+
WHERE d.defaclnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1)
|
|
24
24
|
AND d.defaclobjtype = 'r'
|
|
25
|
-
AND a.grantee = (SELECT oid FROM pg_roles WHERE rolname = $2)
|
|
25
|
+
AND a.grantee = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $2)
|
|
26
26
|
ORDER BY for_role`;
|
|
27
27
|
|
|
28
28
|
export interface LiveDefault {
|
|
@@ -38,7 +38,7 @@ import { readOwnedTables, readSchemaOwnership } from './grants-ownership.ts';
|
|
|
38
38
|
import { type LiveDefault, readDefaultAcls } from './grants-read-defaults.ts';
|
|
39
39
|
import { type AclRow, encodeWord, wordsOf } from './grants-words.ts';
|
|
40
40
|
|
|
41
|
-
const SCHEMA_PRESENT_SQL = 'SELECT 1 AS present FROM pg_namespace WHERE nspname = $1';
|
|
41
|
+
const SCHEMA_PRESENT_SQL = 'SELECT 1 AS present FROM pg_catalog.pg_namespace WHERE nspname = $1';
|
|
42
42
|
|
|
43
43
|
export const schemaExists = (pg: PgExecutor, schema: string): Effect.Effect<boolean, SqlError> =>
|
|
44
44
|
Effect.map(
|
|
@@ -52,10 +52,10 @@ const SCHEMA_ACL_SQL = `SELECT
|
|
|
52
52
|
a.grantee = 0 AS public,
|
|
53
53
|
a.privilege_type AS privilege,
|
|
54
54
|
a.is_grantable AS grantable
|
|
55
|
-
FROM pg_namespace n
|
|
56
|
-
CROSS JOIN LATERAL aclexplode(n.nspacl) AS a
|
|
55
|
+
FROM pg_catalog.pg_namespace n
|
|
56
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(n.nspacl) AS a
|
|
57
57
|
WHERE n.nspname = $1
|
|
58
|
-
AND (a.grantee = 0 OR a.grantee = (SELECT oid FROM pg_roles WHERE rolname = $2))`;
|
|
58
|
+
AND (a.grantee = 0 OR a.grantee = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $2))`;
|
|
59
59
|
|
|
60
60
|
export interface LiveSchemaAcl {
|
|
61
61
|
readonly role: ReadonlyArray<string>;
|
|
@@ -84,10 +84,10 @@ const TABLES_SQL = `SELECT
|
|
|
84
84
|
a.grantee = 0 AS public,
|
|
85
85
|
a.privilege_type AS privilege,
|
|
86
86
|
a.is_grantable AS grantable
|
|
87
|
-
FROM pg_class c
|
|
88
|
-
CROSS JOIN LATERAL aclexplode(c.relacl) AS a
|
|
89
|
-
WHERE c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1)
|
|
90
|
-
AND (a.grantee = 0 OR a.grantee = (SELECT oid FROM pg_roles WHERE rolname = $2))
|
|
87
|
+
FROM pg_catalog.pg_class c
|
|
88
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) AS a
|
|
89
|
+
WHERE c.relnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1)
|
|
90
|
+
AND (a.grantee = 0 OR a.grantee = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $2))
|
|
91
91
|
ORDER BY c.relname`;
|
|
92
92
|
|
|
93
93
|
export interface LiveTable {
|
|
@@ -146,17 +146,17 @@ const COLUMNS_SQL = `SELECT
|
|
|
146
146
|
a.grantee = 0 AS public,
|
|
147
147
|
a.privilege_type AS privilege,
|
|
148
148
|
a.is_grantable AS grantable,
|
|
149
|
-
pg_get_userbyid(a.grantor) AS grantor,
|
|
150
|
-
pg_get_userbyid(c.relowner) AS owner,
|
|
149
|
+
pg_catalog.pg_get_userbyid(a.grantor) AS grantor,
|
|
150
|
+
pg_catalog.pg_get_userbyid(c.relowner) AS owner,
|
|
151
151
|
current_user AS revoker,
|
|
152
|
-
pg_has_role(current_user, c.relowner, 'USAGE') AS revoker_as_owner
|
|
153
|
-
FROM pg_attribute v
|
|
154
|
-
JOIN pg_class c ON c.oid = v.attrelid
|
|
155
|
-
CROSS JOIN LATERAL aclexplode(v.attacl) AS a
|
|
156
|
-
WHERE c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1)
|
|
152
|
+
pg_catalog.pg_has_role(current_user, c.relowner, 'USAGE') AS revoker_as_owner
|
|
153
|
+
FROM pg_catalog.pg_attribute v
|
|
154
|
+
JOIN pg_catalog.pg_class c ON c.oid = v.attrelid
|
|
155
|
+
CROSS JOIN LATERAL pg_catalog.aclexplode(v.attacl) AS a
|
|
156
|
+
WHERE c.relnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = $1)
|
|
157
157
|
AND v.attnum > 0
|
|
158
158
|
AND NOT v.attisdropped
|
|
159
|
-
AND (a.grantee = 0 OR a.grantee = (SELECT oid FROM pg_roles WHERE rolname = $2))
|
|
159
|
+
AND (a.grantee = 0 OR a.grantee = (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = $2))
|
|
160
160
|
ORDER BY c.relname, v.attname`;
|
|
161
161
|
|
|
162
162
|
export interface LiveColumn {
|
package/src/postgres/index.ts
CHANGED
|
@@ -49,7 +49,9 @@ export {
|
|
|
49
49
|
diffPostgresSchema,
|
|
50
50
|
isPostgresSchema,
|
|
51
51
|
} from './schema.ts';
|
|
52
|
+
export { PostgresSchemaCascadeSequencesRefused } from './schema-sequence-error.ts';
|
|
52
53
|
export {
|
|
54
|
+
PostgresSchemaCascadeCrossSchemaRefused,
|
|
53
55
|
PostgresSchemaCreateVanished,
|
|
54
56
|
PostgresSchemaExistsRefused,
|
|
55
57
|
PostgresSchemaIdentityRefused,
|
|
@@ -6,6 +6,7 @@ import { parseLiteral } from './fake-sql-quote.ts';
|
|
|
6
6
|
import { buildCommentSchemaSql } from './schema-sql.ts';
|
|
7
7
|
import { buildSetPasswordSql } from './role-sql.ts';
|
|
8
8
|
import { makePsqlExecutor, redactPasswordLiterals } from './psql-executor.ts';
|
|
9
|
+
import { stripPin } from './search-path.ts';
|
|
9
10
|
|
|
10
11
|
const payload = "\\'; CREATE SCHEMA injected; --";
|
|
11
12
|
const target = { database: 'postgres', username: 'postgres' };
|
|
@@ -22,7 +23,8 @@ for (const on of [true, false]) {
|
|
|
22
23
|
test(`psql executor comment and bound param stay one literal with standard_conforming_strings=${String(on)}`, async () => {
|
|
23
24
|
const fake = makeFakeSql({ schemas: [row], standardConformingStrings: on });
|
|
24
25
|
const calls: string[] = [];
|
|
25
|
-
const pg = makePsqlExecutor(async ({ stdin }) => {
|
|
26
|
+
const pg = makePsqlExecutor(async ({ stdin: raw }) => {
|
|
27
|
+
const stdin = stripPin(raw);
|
|
26
28
|
calls.push(stdin);
|
|
27
29
|
if (stdin.startsWith('COMMENT')) {
|
|
28
30
|
await Effect.runPromise(fake.unsafe(stdin.slice(0, -1)));
|
|
@@ -7,6 +7,7 @@ import { buildCreateDatabaseSql, isDuplicateDatabaseRace } from './database-sql.
|
|
|
7
7
|
import { buildSetPasswordSql } from './role-sql.ts';
|
|
8
8
|
import { scramSha256Verifier } from './role-scram.ts';
|
|
9
9
|
import { type PsqlRunner, inlineParams, makePsqlExecutor } from './psql-executor.ts';
|
|
10
|
+
import { stripPin } from './search-path.ts';
|
|
10
11
|
|
|
11
12
|
const target = { database: 'postgres', username: 'postgres' };
|
|
12
13
|
const ok = (stdout: string) => Promise.resolve({ code: 0, stdout, stderr: '' });
|
|
@@ -24,7 +25,10 @@ describe('psql executor', () => {
|
|
|
24
25
|
return ok('[{"oid":"16400","name":"x"}]\n');
|
|
25
26
|
};
|
|
26
27
|
const rows = await Effect.runPromise(
|
|
27
|
-
makePsqlExecutor(run, target).unsafe(
|
|
28
|
+
makePsqlExecutor(run, target).unsafe(
|
|
29
|
+
'SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1',
|
|
30
|
+
['x'],
|
|
31
|
+
),
|
|
28
32
|
);
|
|
29
33
|
expect(rows).toEqual([{ oid: 16400, name: 'x' }]);
|
|
30
34
|
expect(calls[0]?.argv.slice(0, 1)).toEqual(['psql']);
|
|
@@ -114,10 +118,12 @@ describe('runner transport through withPg', () => {
|
|
|
114
118
|
tablespace: 'pg_default',
|
|
115
119
|
};
|
|
116
120
|
let created = false;
|
|
117
|
-
const run: PsqlRunner = ({ stdin }) => {
|
|
121
|
+
const run: PsqlRunner = ({ stdin: raw }) => {
|
|
122
|
+
const stdin = stripPin(raw);
|
|
118
123
|
stdins.push(stdin);
|
|
119
|
-
if (stdin.includes('pg_roles')) return ok('[{"present":1}]');
|
|
120
|
-
if (stdin.includes('FROM pg_database'))
|
|
124
|
+
if (stdin.includes('pg_catalog.pg_roles')) return ok('[{"present":1}]');
|
|
125
|
+
if (stdin.includes('FROM pg_catalog.pg_database'))
|
|
126
|
+
return ok(created ? JSON.stringify([row]) : '[]');
|
|
121
127
|
if (stdin.startsWith('CREATE DATABASE')) created = true;
|
|
122
128
|
return ok('');
|
|
123
129
|
};
|
|
@@ -141,7 +147,7 @@ describe('runner transport through withPg', () => {
|
|
|
141
147
|
test('transaction is one psql script from BEGIN through COMMIT', async () => {
|
|
142
148
|
const calls: string[] = [];
|
|
143
149
|
const run: PsqlRunner = (call) => {
|
|
144
|
-
calls.push(call.stdin);
|
|
150
|
+
calls.push(stripPin(call.stdin));
|
|
145
151
|
return ok('');
|
|
146
152
|
};
|
|
147
153
|
await Effect.runPromise(
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* ★ ROWS COME BACK AS JSON: a row-returning statement (`SELECT`, or a `WITH` query) is wrapped
|
|
15
15
|
* in `json_agg`, so `oid` and `datconnlimit` arrive as JS numbers exactly as the socket client
|
|
16
16
|
* decodes them (database-sql.ts, no bigint).
|
|
17
|
+
* ⛔ EVERY SCRIPT IS PREFIXED WITH `SET search_path = pg_catalog, pg_temp;` — see `search-path.ts`.
|
|
17
18
|
* ★ `VERBOSITY=verbose` puts the SQLSTATE in the error line, so `42P04` (duplicate database)
|
|
18
19
|
* stays recognisable to `isDuplicateDatabaseRace`.
|
|
19
20
|
*/
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
UnknownError,
|
|
26
27
|
} from 'effect/unstable/sql/SqlError';
|
|
27
28
|
import { type PgExecutor, quoteStringLiteral } from './database-sql.ts';
|
|
29
|
+
import { PIN_SCRIPT_PREFIX } from './search-path.ts';
|
|
28
30
|
|
|
29
31
|
export interface PsqlResult {
|
|
30
32
|
readonly code: number;
|
|
@@ -122,7 +124,8 @@ const runScript = (
|
|
|
122
124
|
'-d',
|
|
123
125
|
target.database,
|
|
124
126
|
],
|
|
125
|
-
|
|
127
|
+
// ⛔ EVERY psql SESSION STARTS PINNED (`search-path.ts`): one process, one session.
|
|
128
|
+
stdin: `${PIN_SCRIPT_PREFIX}${stdin}`,
|
|
126
129
|
}),
|
|
127
130
|
catch: (cause) =>
|
|
128
131
|
new SqlError({ reason: new ConnectionError({ cause, operation: 'psql exec' }) }),
|
|
@@ -13,6 +13,7 @@ import * as Effect from 'effect/Effect';
|
|
|
13
13
|
import { postgresRunnerConnection } from './connection.ts';
|
|
14
14
|
import { parseLiteral } from './fake-sql-quote.ts';
|
|
15
15
|
import { makeFakeSql } from './fake-sql.ts';
|
|
16
|
+
import { stripPin } from './search-path.ts';
|
|
16
17
|
import { type PsqlRunner } from './psql-executor.ts';
|
|
17
18
|
import { postgresRoleHandlers } from './role-provider.ts';
|
|
18
19
|
import type { PostgresRoleAttributes, PostgresRoleProps } from './role-attrs.ts';
|
|
@@ -42,7 +43,8 @@ const liveRole = (): PostgresRoleAttributes => ({
|
|
|
42
43
|
*/
|
|
43
44
|
const runnerFor =
|
|
44
45
|
(fake: ReturnType<typeof makeFakeSql>): PsqlRunner =>
|
|
45
|
-
async ({ stdin }) => {
|
|
46
|
+
async ({ stdin: raw }) => {
|
|
47
|
+
const stdin = stripPin(raw);
|
|
46
48
|
const wrapped = /^SELECT coalesce\(json_agg\(t\), '\[\]'::json\)::text FROM \((.*)\) t;$/s.exec(
|
|
47
49
|
stdin,
|
|
48
50
|
);
|
|
@@ -3,6 +3,7 @@ import * as Effect from 'effect/Effect';
|
|
|
3
3
|
import { makeFakeSql } from './fake-sql.ts';
|
|
4
4
|
import { buildRepairMembershipSql, syncMemberships } from './role-membership-sql.ts';
|
|
5
5
|
import { makePsqlExecutor } from './psql-executor.ts';
|
|
6
|
+
import { stripPin } from './search-path.ts';
|
|
6
7
|
|
|
7
8
|
const props = {
|
|
8
9
|
name: 'seat',
|
|
@@ -34,7 +35,8 @@ test.each(['2BP01', '42501'])(
|
|
|
34
35
|
// denial); the real server is the dependency authority.
|
|
35
36
|
const scripts: string[] = [];
|
|
36
37
|
const pg = makePsqlExecutor(
|
|
37
|
-
({ stdin }) => {
|
|
38
|
+
({ stdin: raw }) => {
|
|
39
|
+
const stdin = stripPin(raw);
|
|
38
40
|
scripts.push(stdin);
|
|
39
41
|
return Promise.resolve(
|
|
40
42
|
stdin.startsWith('BEGIN')
|
|
@@ -84,9 +84,9 @@ test('reads INHERIT and repairs another grantor row for declared NOINHERIT', asy
|
|
|
84
84
|
expect(before?.memberships).toEqual([
|
|
85
85
|
{ parent: 'parent', grantor: 'other', admin: false, set: false, inherit: true },
|
|
86
86
|
]);
|
|
87
|
-
expect(
|
|
88
|
-
|
|
89
|
-
);
|
|
87
|
+
expect(
|
|
88
|
+
fake.statements.find((s) => s.text.includes('FROM pg_catalog.pg_auth_members'))?.text,
|
|
89
|
+
).toContain('m.inherit_option AS inherit');
|
|
90
90
|
expect(await Effect.runPromise(diffPostgresRole(props, live, {}, { found: before }))).toEqual({
|
|
91
91
|
action: 'update',
|
|
92
92
|
});
|
|
@@ -40,10 +40,10 @@ const MEMBERSHIP_SQL = `SELECT parent.rolname AS parent,
|
|
|
40
40
|
m.admin_option AS admin,
|
|
41
41
|
m.set_option AS set,
|
|
42
42
|
m.inherit_option AS inherit
|
|
43
|
-
FROM pg_auth_members m
|
|
44
|
-
JOIN pg_roles member ON member.oid = m.member
|
|
45
|
-
JOIN pg_roles parent ON parent.oid = m.roleid
|
|
46
|
-
LEFT JOIN pg_roles grantor ON grantor.oid = m.grantor
|
|
43
|
+
FROM pg_catalog.pg_auth_members m
|
|
44
|
+
JOIN pg_catalog.pg_roles member ON member.oid = m.member
|
|
45
|
+
JOIN pg_catalog.pg_roles parent ON parent.oid = m.roleid
|
|
46
|
+
LEFT JOIN pg_catalog.pg_roles grantor ON grantor.oid = m.grantor
|
|
47
47
|
WHERE member.rolname = $1
|
|
48
48
|
ORDER BY parent.rolname, grantor.rolname NULLS FIRST`;
|
|
49
49
|
|
|
@@ -9,6 +9,7 @@ import type { Connection } from 'effect/unstable/sql/SqlConnection';
|
|
|
9
9
|
import type { PgExecutor } from './database-sql.ts';
|
|
10
10
|
import { makeFakeSql } from './fake-sql.ts';
|
|
11
11
|
import { type PsqlRunner, makePsqlExecutor } from './psql-executor.ts';
|
|
12
|
+
import { stripPin } from './search-path.ts';
|
|
12
13
|
import { reconcileWithClient } from './role.ts';
|
|
13
14
|
|
|
14
15
|
const props = {
|
|
@@ -84,7 +85,8 @@ test('psql runner receives only a verifier; echoed failure and state hold no pla
|
|
|
84
85
|
for (const fail of [false, true]) {
|
|
85
86
|
const calls: Parameters<PsqlRunner>[0][] = [];
|
|
86
87
|
let created = exists;
|
|
87
|
-
const run: PsqlRunner = (
|
|
88
|
+
const run: PsqlRunner = (raw) => {
|
|
89
|
+
const call = { ...raw, stdin: stripPin(raw.stdin) };
|
|
88
90
|
calls.push(call);
|
|
89
91
|
if (call.stdin.includes('PASSWORD') && fail) {
|
|
90
92
|
return Promise.resolve({
|
|
@@ -97,7 +99,7 @@ test('psql runner receives only a verifier; echoed failure and state hold no pla
|
|
|
97
99
|
return Promise.resolve({
|
|
98
100
|
code: 0,
|
|
99
101
|
stderr: '',
|
|
100
|
-
stdout: call.stdin.includes('FROM pg_roles r')
|
|
102
|
+
stdout: call.stdin.includes('FROM pg_catalog.pg_roles r')
|
|
101
103
|
? JSON.stringify(created ? [live] : [])
|
|
102
104
|
: '[]',
|
|
103
105
|
});
|
package/src/postgres/role-sql.ts
CHANGED
|
@@ -46,7 +46,7 @@ const SELECT_ROLE_SQL = `SELECT
|
|
|
46
46
|
r.rolreplication AS replication,
|
|
47
47
|
r.rolbypassrls AS bypassrls,
|
|
48
48
|
to_char(r.rolvaliduntil AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "validUntil"
|
|
49
|
-
FROM pg_roles r
|
|
49
|
+
FROM pg_catalog.pg_roles r
|
|
50
50
|
WHERE r.rolname = $1`;
|
|
51
51
|
|
|
52
52
|
export const selectRole = (
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dependency closure `DROP SCHEMA … CASCADE` would walk, and the count of what it would take
|
|
3
|
+
* that does NOT live in the schema being dropped (`schema-drop-sql.ts` refuses when it is > 0).
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE CLOSURE IS DERIVED FROM THE NAMESPACE OBJECT, NOT FROM A LIST OF CLASSES. Until
|
|
6
|
+
* 2026-10-06 the scan started from four catalogs (`pg_class`, `pg_type`, `pg_proc`,
|
|
7
|
+
* `pg_operator`), so `a.c` (a collation) used by `b.t.x` was invisible and the cascade reached
|
|
8
|
+
* into `b.t.x` (Codex read of PR 357). Now the walk starts at `(pg_namespace, v_ns)` and follows
|
|
9
|
+
* EVERY `pg_depend` row whose referenced object is already inside — the same edges
|
|
10
|
+
* `findDependentObjects` (`dependency.c`) follows — so any object class `pg_depend` can name
|
|
11
|
+
* (collation, conversion, opclass, opfamily, text-search, statistics, extension members, …)
|
|
12
|
+
* is reached with no catalog enumerated to find it.
|
|
13
|
+
* ★ EVERY `deptype` FOLLOWS (`pg_depend`, catalogs.sgml); they differ only in attribution:
|
|
14
|
+
* n NORMAL cascade drops the dependent → followed; foreign if it lives elsewhere
|
|
15
|
+
* a AUTO dropped with any drop → followed; foreign if it lives elsewhere
|
|
16
|
+
* (an owned sequence in another schema is dropped with its table)
|
|
17
|
+
* i INTERNAL a part of the referenced object → followed; attributed to its owner (below)
|
|
18
|
+
* P / S partition primary / secondary → followed; a partition in another
|
|
19
|
+
* schema is dropped with its parent, so it is foreign
|
|
20
|
+
* e EXTENSION member of an extension → followed; the extension's members are
|
|
21
|
+
* dropped with it, wherever they live
|
|
22
|
+
* x AUTO-EXT auto, and dropped with the extension → followed like `a`
|
|
23
|
+
* p PIN system object, `refobjid = 0` → never a referenced row, never joins
|
|
24
|
+
* ★ "INSIDE" is a home-schema test per class. TOAST is the case the old scan got wrong: a text
|
|
25
|
+
* table's TOAST relation (and its index) lives in `pg_toast` and depends internally on the
|
|
26
|
+
* table, so it is attributed to the table that owns it via `reltoastrelid` — otherwise every
|
|
27
|
+
* cascade of a schema with a text column was refused. Constraints, triggers, rules, policies
|
|
28
|
+
* and defaults have no schema of their own and take their table's.
|
|
29
|
+
* ⚠️ A CLASS THIS FILE DOES NOT KNOW (a cast, a foreign server, a language, an event trigger)
|
|
30
|
+
* resolves to NULL, which is NOT inside: it counts as foreign. Unknown fails CLOSED. The walk
|
|
31
|
+
* stops at a foreign object — one is enough to refuse.
|
|
32
|
+
* ★ A dependent created AFTER this scan is held off by the caller's locks (`schema-drop-sql.ts`).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const cls = (name: string): string => `'pg_catalog.${name}'::pg_catalog.regclass`;
|
|
36
|
+
|
|
37
|
+
/** `WHEN <class> THEN <namespace column of the row>` for a catalog that carries its own. */
|
|
38
|
+
const direct = (table: string, column: string): string =>
|
|
39
|
+
`WHEN ${cls(table)} THEN (SELECT x.${column} FROM pg_catalog.${table} x WHERE x.oid = d.objid)`;
|
|
40
|
+
|
|
41
|
+
/** The same for an object with no schema of its own: the schema of the relation it hangs on. */
|
|
42
|
+
const viaRelation = (table: string, column: string): string =>
|
|
43
|
+
`WHEN ${cls(table)} THEN (SELECT c.relnamespace FROM pg_catalog.${table} x JOIN pg_catalog.pg_class c ON c.oid = x.${column} WHERE x.oid = d.objid)`;
|
|
44
|
+
|
|
45
|
+
/** An access-method member belongs to its operator family. */
|
|
46
|
+
const viaFamily = (table: string, column: string): string =>
|
|
47
|
+
`WHEN ${cls(table)} THEN (SELECT f.opfnamespace FROM pg_catalog.${table} x JOIN pg_catalog.pg_opfamily f ON f.oid = x.${column} WHERE x.oid = d.objid)`;
|
|
48
|
+
|
|
49
|
+
/** A relation's home. A TOAST relation, or the index on one, is its owner's. */
|
|
50
|
+
const RELATION_HOME = `WHEN ${cls('pg_class')} THEN COALESCE(
|
|
51
|
+
(SELECT o.relnamespace FROM pg_catalog.pg_class o
|
|
52
|
+
WHERE o.reltoastrelid <> 0
|
|
53
|
+
AND o.reltoastrelid IN (d.objid, (SELECT i.indrelid FROM pg_catalog.pg_index i WHERE i.indexrelid = d.objid))),
|
|
54
|
+
(SELECT x.relnamespace FROM pg_catalog.pg_class x WHERE x.oid = d.objid))`;
|
|
55
|
+
|
|
56
|
+
const HOME_OF_DEPENDENT = `CASE d.classid
|
|
57
|
+
WHEN ${cls('pg_namespace')} THEN d.objid
|
|
58
|
+
${RELATION_HOME}
|
|
59
|
+
${direct('pg_type', 'typnamespace')}
|
|
60
|
+
${direct('pg_proc', 'pronamespace')}
|
|
61
|
+
${direct('pg_operator', 'oprnamespace')}
|
|
62
|
+
${direct('pg_collation', 'collnamespace')}
|
|
63
|
+
${direct('pg_conversion', 'connamespace')}
|
|
64
|
+
${direct('pg_opclass', 'opcnamespace')}
|
|
65
|
+
${direct('pg_opfamily', 'opfnamespace')}
|
|
66
|
+
${direct('pg_ts_config', 'cfgnamespace')}
|
|
67
|
+
${direct('pg_ts_dict', 'dictnamespace')}
|
|
68
|
+
${direct('pg_ts_parser', 'prsnamespace')}
|
|
69
|
+
${direct('pg_ts_template', 'tmplnamespace')}
|
|
70
|
+
${direct('pg_statistic_ext', 'stxnamespace')}
|
|
71
|
+
${direct('pg_constraint', 'connamespace')}
|
|
72
|
+
${direct('pg_default_acl', 'defaclnamespace')}
|
|
73
|
+
${direct('pg_extension', 'extnamespace')}
|
|
74
|
+
${viaRelation('pg_trigger', 'tgrelid')}
|
|
75
|
+
${viaRelation('pg_rewrite', 'ev_class')}
|
|
76
|
+
${viaRelation('pg_attrdef', 'adrelid')}
|
|
77
|
+
${viaRelation('pg_policy', 'polrelid')}
|
|
78
|
+
${viaRelation('pg_publication_rel', 'prrelid')}
|
|
79
|
+
${viaFamily('pg_amop', 'amopfamily')}
|
|
80
|
+
${viaFamily('pg_amproc', 'amprocfamily')}
|
|
81
|
+
END`;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `v_count := …`: how many objects the cascade would drop outside `v_ns`, PLUS how many objects
|
|
85
|
+
* INSIDE it hold an `e` or `i` edge to an object outside the closure.
|
|
86
|
+
* ⛔ THE SECOND TERM IS THE OWNERSHIP ESCAPE (Codex read of PR 357, 2026-10-06). An inside table
|
|
87
|
+
* added to a foreign extension (`ALTER EXTENSION x ADD TABLE a.t`) has an OUTGOING `e` edge; the
|
|
88
|
+
* walk only follows edges INTO the closure, so it saw nothing, while `findDependentObjects`
|
|
89
|
+
* redirects the deletion of an extension member / internal part to its OWNER and drops that
|
|
90
|
+
* owner's other objects too. Any outgoing `e` or `i` edge leaving the closure counts as foreign:
|
|
91
|
+
* refuse, never follow-and-classify (the simplest correct answer fails closed).
|
|
92
|
+
*/
|
|
93
|
+
export const OUTSIDE_DEPENDENTS_SQL = `v_count := (
|
|
94
|
+
WITH RECURSIVE reach (classid, objid, inside) AS (
|
|
95
|
+
SELECT ${cls('pg_namespace')}::pg_catalog.oid, v_ns, true
|
|
96
|
+
UNION
|
|
97
|
+
SELECT d.classid, d.objid, (${HOME_OF_DEPENDENT}) IS NOT DISTINCT FROM v_ns
|
|
98
|
+
FROM pg_catalog.pg_depend d
|
|
99
|
+
JOIN reach r ON d.refclassid = r.classid AND d.refobjid = r.objid AND r.inside
|
|
100
|
+
WHERE d.deptype <> 'p'
|
|
101
|
+
)
|
|
102
|
+
SELECT (SELECT pg_catalog.count(*) FROM reach WHERE NOT inside)
|
|
103
|
+
+ (SELECT pg_catalog.count(*) FROM pg_catalog.pg_depend e
|
|
104
|
+
JOIN reach r ON e.classid = r.classid AND e.objid = r.objid AND r.inside
|
|
105
|
+
WHERE e.deptype IN ('e', 'i') AND e.refobjid <> 0
|
|
106
|
+
AND NOT EXISTS (SELECT 1 FROM reach o
|
|
107
|
+
WHERE o.inside AND o.classid = e.refclassid AND o.objid = e.refobjid))
|
|
108
|
+
)`;
|