Skip to content

Commit d228868

Browse files
authored
Merge pull request #1399 from constructive-io/feat/export-exclude-categories
feat(export): explicit excludeCategories filtering in exportMigrations
2 parents 08b1292 + 89fcfbd commit d228868

7 files changed

Lines changed: 241 additions & 20 deletions

File tree

pgpm/cli/src/commands/export.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@ Options:
2323
--author <name> Project author (default: from git config)
2424
--extensionName <name> Extension name
2525
--metaExtensionName <name> Meta extension name (default: svc)
26+
--exclude-categories <list> Comma-separated sql_actions categories to omit
27+
(e.g. security,permissions,auth,memberships). Works in SQL and GraphQL modes.
2628
--cwd <directory> Working directory (default: current directory)
2729
2830
Examples:
2931
pgpm export Export migrations from selected database (SQL mode)
32+
pgpm export --exclude-categories security,permissions,auth,memberships
3033
pgpm export --graphql-endpoint 'http://[::1]:3002/graphql' --migrate-endpoint 'http://[::1]:3000/graphql' --migrate-host db_migrate.localhost:3000
3134
`;
3235

@@ -53,6 +56,14 @@ export default async (
5356
const migrateHeadersRaw = argv['migrate-headers'] || argv.migrateHeaders;
5457
const token = argv.token;
5558

59+
const excludeCategoriesRaw = argv['exclude-categories'] || argv.excludeCategories;
60+
const excludeCategories: string[] | undefined =
61+
typeof excludeCategoriesRaw === 'string'
62+
? excludeCategoriesRaw.split(',').map((s: string) => s.trim()).filter(Boolean)
63+
: Array.isArray(excludeCategoriesRaw)
64+
? excludeCategoriesRaw
65+
: undefined;
66+
5667
if (graphqlEndpoint) {
5768
// =========================================================================
5869
// GraphQL export mode
@@ -168,7 +179,8 @@ export default async (
168179
metaExtensionName,
169180
prompter,
170181
argv,
171-
username
182+
username,
183+
excludeCategories
172184
});
173185
} else {
174186
// =========================================================================
@@ -305,7 +317,8 @@ export default async (
305317
metaExtensionName,
306318
prompter,
307319
argv,
308-
username
320+
username,
321+
excludeCategories
309322
});
310323
}
311324

pgpm/export/__tests__/export-flow.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,4 +759,159 @@ relocatable = false
759759
}
760760
});
761761
});
762+
763+
describe('Export with excludeCategories', () => {
764+
const DATABASE_ID = 'a1b2c3d4-e5f6-4708-b250-000000000001';
765+
const OPEN_EXTENSION_NAME = 'pets-open';
766+
const OPEN_META_EXTENSION_NAME = 'pets-open-svc';
767+
const POLICY_DEPLOY = 'schemas/pets_public/tables/pets/policies/pets_policy';
768+
let exportError: Error | null = null;
769+
770+
const scaffoldModule = (name: string, description: string) => {
771+
const moduleDir = join(exportWorkspaceDir, 'packages', name);
772+
mkdirSync(moduleDir, { recursive: true });
773+
writeFileSync(join(moduleDir, 'package.json'), JSON.stringify({
774+
name,
775+
version: '1.0.0',
776+
description
777+
}, null, 2));
778+
writeFileSync(join(moduleDir, `${name}.control`), `# ${name} extension
779+
comment = '${description}'
780+
default_version = '1.0.0'
781+
relocatable = false
782+
`);
783+
writeFileSync(join(moduleDir, 'pgpm.plan'), `%syntax-version=1.0.0
784+
%project=${name}
785+
%uri=https://github.com/test/${name}
786+
`);
787+
};
788+
789+
beforeAll(async () => {
790+
// Add a category column (the real db_migrate.sql_actions has one) and
791+
// tag a policy action as 'security'
792+
await pg.query(`ALTER TABLE db_migrate.sql_actions ADD COLUMN IF NOT EXISTS category text DEFAULT 'ddl'`);
793+
await pg.query(
794+
`INSERT INTO db_migrate.sql_actions (name, database_id, deploy, deps, content, revert, verify, category)
795+
VALUES (
796+
'Create pets policy',
797+
$1,
798+
$2,
799+
ARRAY['schemas/pets_public/tables/pets/table']::text[],
800+
'CREATE POLICY pets_policy ON pets_public.pets FOR SELECT USING (true);',
801+
'DROP POLICY pets_policy ON pets_public.pets;',
802+
'SELECT 1;',
803+
'security'
804+
)
805+
ON CONFLICT (database_id, deploy) DO NOTHING`,
806+
[DATABASE_ID, POLICY_DEPLOY]
807+
);
808+
809+
const schemaResult = await pg.query(
810+
'SELECT schema_name FROM metaschema_public.schema WHERE database_id = $1',
811+
[DATABASE_ID]
812+
);
813+
const schemaNames = schemaResult.rows.map((r: any) => r.schema_name);
814+
815+
scaffoldModule(OPEN_EXTENSION_NAME, 'Exported pets database schema (security excluded)');
816+
scaffoldModule(OPEN_META_EXTENSION_NAME, 'Exported pets service metadata (security excluded)');
817+
818+
const project = new PgpmPackage(exportWorkspaceDir);
819+
820+
try {
821+
await exportMigrations({
822+
project,
823+
options: {
824+
pg: dbConfig
825+
},
826+
dbInfo: {
827+
dbname: dbConfig.database,
828+
databaseName: 'pets',
829+
database_ids: [DATABASE_ID]
830+
},
831+
author: 'test <test@test.local>',
832+
outdir: join(exportWorkspaceDir, 'packages'),
833+
schema_names: schemaNames,
834+
extensionName: OPEN_EXTENSION_NAME,
835+
extensionDesc: 'Exported pets database schema (security excluded)',
836+
metaExtensionName: OPEN_META_EXTENSION_NAME,
837+
metaExtensionDesc: 'Exported pets service metadata (security excluded)',
838+
excludeCategories: ['security']
839+
});
840+
} catch (err) {
841+
exportError = err as Error;
842+
}
843+
}, 180000);
844+
845+
it('should have completed export without errors', () => {
846+
if (exportError) {
847+
console.error('Export error:', exportError);
848+
}
849+
expect(exportError).toBeNull();
850+
});
851+
852+
it('should omit security-categorized actions from the plan', () => {
853+
const planPath = join(exportWorkspaceDir, 'packages', OPEN_EXTENSION_NAME, 'pgpm.plan');
854+
expect(existsSync(planPath)).toBe(true);
855+
856+
// The replacer renames pets_public -> pets_open_public in the exported module
857+
const planContent = readFileSync(planPath, 'utf-8');
858+
expect(planContent).not.toContain('/policies/pets_policy');
859+
// Non-security actions (category 'ddl' via the column default) are kept
860+
expect(planContent).toContain('schemas/pets_open_public/tables/pets/table');
861+
});
862+
863+
it('should not write deploy files for excluded actions', () => {
864+
const policyDeployPath = join(
865+
exportWorkspaceDir, 'packages', OPEN_EXTENSION_NAME, 'deploy',
866+
'schemas/pets_open_public/tables/pets/policies/pets_policy.sql'
867+
);
868+
expect(existsSync(policyDeployPath)).toBe(false);
869+
870+
const tableDeployPath = join(
871+
exportWorkspaceDir, 'packages', OPEN_EXTENSION_NAME, 'deploy', 'schemas/pets_open_public/tables/pets/table.sql'
872+
);
873+
expect(existsSync(tableDeployPath)).toBe(true);
874+
});
875+
876+
it('should include security actions when excludeCategories is not passed', async () => {
877+
const FULL_EXTENSION_NAME = 'pets-full';
878+
const FULL_META_EXTENSION_NAME = 'pets-full-svc';
879+
880+
const schemaResult = await pg.query(
881+
'SELECT schema_name FROM metaschema_public.schema WHERE database_id = $1',
882+
[DATABASE_ID]
883+
);
884+
const schemaNames = schemaResult.rows.map((r: any) => r.schema_name);
885+
886+
scaffoldModule(FULL_EXTENSION_NAME, 'Exported pets database schema (full)');
887+
scaffoldModule(FULL_META_EXTENSION_NAME, 'Exported pets service metadata (full)');
888+
889+
const project = new PgpmPackage(exportWorkspaceDir);
890+
891+
await exportMigrations({
892+
project,
893+
options: {
894+
pg: dbConfig
895+
},
896+
dbInfo: {
897+
dbname: dbConfig.database,
898+
databaseName: 'pets',
899+
database_ids: [DATABASE_ID]
900+
},
901+
author: 'test <test@test.local>',
902+
outdir: join(exportWorkspaceDir, 'packages'),
903+
schema_names: schemaNames,
904+
extensionName: FULL_EXTENSION_NAME,
905+
extensionDesc: 'Exported pets database schema (full)',
906+
metaExtensionName: FULL_META_EXTENSION_NAME,
907+
metaExtensionDesc: 'Exported pets service metadata (full)'
908+
});
909+
910+
const planContent = readFileSync(
911+
join(exportWorkspaceDir, 'packages', FULL_EXTENSION_NAME, 'pgpm.plan'),
912+
'utf-8'
913+
);
914+
expect(planContent).toContain('schemas/pets_full_public/tables/pets/policies/pets_policy');
915+
}, 180000);
916+
});
762917
});

pgpm/export/src/export-graphql.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ export interface ExportGraphQLOptions {
7070
username?: string;
7171
serviceOutdir?: string;
7272
skipSchemaRenaming?: boolean;
73+
/**
74+
* sql_actions categories to exclude from the export (e.g. ['security',
75+
* 'permissions']). Rows whose category matches are omitted; rows with a
76+
* null category are always kept.
77+
*/
78+
excludeCategories?: string[];
7379
}
7480

7581
export const exportGraphQL = async ({
@@ -94,7 +100,8 @@ export const exportGraphQL = async ({
94100
repoName,
95101
username,
96102
serviceOutdir,
97-
skipSchemaRenaming = false
103+
skipSchemaRenaming = false,
104+
excludeCategories
98105
}: ExportGraphQLOptions): Promise<void> => {
99106
const normalizedOutdir = normalizeOutdir(outdir);
100107
const svcOutdir = normalizeOutdir(serviceOutdir || outdir);
@@ -147,10 +154,19 @@ export const exportGraphQL = async ({
147154
actionName: true,
148155
actionId: true,
149156
actorId: true,
150-
payload: true
157+
payload: true,
158+
category: true
151159
},
152160
where: {
153-
databaseId: { equalTo: databaseId }
161+
databaseId: { equalTo: databaseId },
162+
...(excludeCategories && excludeCategories.length > 0
163+
? {
164+
or: [
165+
{ category: { isNull: true } },
166+
{ category: { notIn: excludeCategories } }
167+
]
168+
}
169+
: {})
154170
},
155171
orderBy: ['ID_ASC'],
156172
first: PAGE_SIZE,

pgpm/export/src/export-migrations.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ interface ExportMigrationsToDiskOptions {
4444
* Useful for self-referential introspection where you want to apply policies to real schemas.
4545
*/
4646
skipSchemaRenaming?: boolean;
47+
/**
48+
* Action categories to exclude from the export (e.g. ['security', 'permissions']).
49+
* Rows in db_migrate.sql_actions whose category matches are omitted from the
50+
* generated pgpm plan and SQL files. Rows with a NULL category are always kept.
51+
*/
52+
excludeCategories?: string[];
4753
}
4854

4955
interface ExportOptions {
@@ -75,6 +81,12 @@ interface ExportOptions {
7581
* Useful for self-referential introspection where you want to apply policies to real schemas.
7682
*/
7783
skipSchemaRenaming?: boolean;
84+
/**
85+
* Action categories to exclude from the export (e.g. ['security', 'permissions']).
86+
* Rows in db_migrate.sql_actions whose category matches are omitted from the
87+
* generated pgpm plan and SQL files. Rows with a NULL category are always kept.
88+
*/
89+
excludeCategories?: string[];
7890
}
7991

8092
const exportMigrationsToDisk = async ({
@@ -95,7 +107,8 @@ const exportMigrationsToDisk = async ({
95107
repoName,
96108
username,
97109
serviceOutdir,
98-
skipSchemaRenaming = false
110+
skipSchemaRenaming = false,
111+
excludeCategories
99112
}: ExportMigrationsToDiskOptions): Promise<void> => {
100113
const normalizedOutdir = normalizeOutdir(outdir);
101114
// Use serviceOutdir for service module, defaulting to outdir if not provided
@@ -140,13 +153,22 @@ const exportMigrationsToDisk = async ({
140153
name
141154
});
142155

143-
// Filter sql_actions by database_id to avoid cross-database pollution
144-
// Previously this query had no WHERE clause, which could export actions
145-
// from unrelated databases in a persistent database environment
146-
const results = await pgPool.query(
147-
`select * from db_migrate.sql_actions where database_id = $1 order by id`,
148-
[databaseId]
149-
);
156+
// Filter sql_actions by database_id to avoid cross-database pollution.
157+
// Category exclusion is applied here explicitly rather than via the
158+
// export.exclude_categories session GUC, which only affects the session
159+
// it was set on and therefore cannot be relied upon across connections.
160+
const results = excludeCategories && excludeCategories.length > 0
161+
? await pgPool.query(
162+
`select * from db_migrate.sql_actions
163+
where database_id = $1
164+
and (category is null or category != ALL($2::text[]))
165+
order by id`,
166+
[databaseId, excludeCategories]
167+
)
168+
: await pgPool.query(
169+
`select * from db_migrate.sql_actions where database_id = $1 order by id`,
170+
[databaseId]
171+
);
150172

151173
const opts: SqlWriteOptions = {
152174
name,
@@ -276,8 +298,6 @@ ${META_COMMON_FOOTER}
276298

277299
writePgpmPlan(metaPackage, opts);
278300
writePgpmFiles(metaPackage, opts);
279-
280-
pgPool.end();
281301
};
282302

283303
export const exportMigrations = async ({
@@ -296,7 +316,8 @@ export const exportMigrations = async ({
296316
repoName,
297317
username,
298318
serviceOutdir,
299-
skipSchemaRenaming
319+
skipSchemaRenaming,
320+
excludeCategories
300321
}: ExportOptions): Promise<void> => {
301322
for (let v = 0; v < dbInfo.database_ids.length; v++) {
302323
const databaseId = dbInfo.database_ids[v];
@@ -318,7 +339,8 @@ export const exportMigrations = async ({
318339
repoName,
319340
username,
320341
serviceOutdir,
321-
skipSchemaRenaming
342+
skipSchemaRenaming,
343+
excludeCategories
322344
});
323345
}
324346
};

sdk/migrate-client/schemas/migrate.graphql

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@ type SqlAction {
662662
actionId: UUID
663663
actionName: String
664664
actorId: UUID
665+
category: String
665666
content: String
666667
createdAt: Datetime
667668
databaseId: UUID
@@ -716,6 +717,9 @@ input SqlActionFilter {
716717
"""Checks for all expressions in this list."""
717718
and: [SqlActionFilter!]
718719

720+
"""Filter by the object’s `category` field."""
721+
category: StringFilter
722+
719723
"""Filter by the object’s `content` field."""
720724
content: StringFilter
721725

@@ -758,6 +762,8 @@ enum SqlActionOrderBy {
758762
ACTION_NAME_DESC
759763
ACTOR_ID_ASC
760764
ACTOR_ID_DESC
765+
CATEGORY_ASC
766+
CATEGORY_DESC
761767
CONTENT_ASC
762768
CONTENT_DESC
763769
CREATED_AT_ASC

0 commit comments

Comments
 (0)