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
103 changes: 103 additions & 0 deletions pgpm/export/__tests__/export-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
exportTableData,
exportTablesData,
getDataExportColumns,
getMetadataExportColumns,
isVolatileTimestampColumn
} from '../src/export-data';

Expand Down Expand Up @@ -114,6 +115,108 @@ describe('getDataExportColumns', () => {
});
});

describe('getMetadataExportColumns', () => {
it('classifies metadata-described columns identically to a physical table', async () => {
// Same columns as app_routing_public.apis, described purely as metadata
// (name/type/default expression) with no physical table present.
const classified = await getMetadataExportColumns(pg, [
{ name: 'id', type: 'uuid', columnDefault: null },
{ name: 'name', type: 'text', columnDefault: null },
{ name: 'dbname', type: 'text', columnDefault: 'current_database()' },
{ name: 'is_public', type: 'boolean', columnDefault: 'true' },
{ name: 'created_at', type: 'timestamptz', columnDefault: 'now()' },
{ name: 'updated_at', type: 'timestamptz', columnDefault: 'CURRENT_TIMESTAMP' },
{ name: 'observed_at', type: 'timestamptz', columnDefault: 'clock_timestamp()' },
{ name: 'expires_at', type: 'timestamptz', columnDefault: "now() + '2 days'::interval" },
{ name: 'fixed_at', type: 'timestamptz', columnDefault: "'2020-01-01T00:00:00Z'::timestamptz" }
]);
const byName = Object.fromEntries(classified.map(c => [c.name, c]));

// Wall-clock timestamp defaults — all volatile
expect(byName.created_at.volatileDefault).toBe(true);
expect(byName.updated_at.volatileDefault).toBe(true);
expect(byName.observed_at.volatileDefault).toBe(true);
expect(byName.expires_at.volatileDefault).toBe(true);

// Constant timestamp default — immutable
expect(byName.fixed_at.volatileDefault).toBe(false);

// Stable but non-timestamp default
expect(byName.dbname.volatileDefault).toBe(true);

// No default / constant defaults
expect(byName.id.volatileDefault).toBe(false);
expect(byName.is_public.volatileDefault).toBe(false);

// Types are canonicalised through the catalog, matching the physical path
expect(byName.created_at.type).toBe('timestamp with time zone');
expect(byName.name.type).toBe('text');

// Matches getDataExportColumns over the equivalent physical table
const physical = await getDataExportColumns(pg, 'app_routing_public', 'apis');
const physicalByName = Object.fromEntries(physical.map(c => [c.name, c]));
for (const name of Object.keys(byName)) {
expect(byName[name].volatileDefault).toBe(physicalByName[name].volatileDefault);
expect(byName[name].type).toBe(physicalByName[name].type);
}
});

it('feeds isVolatileTimestampColumn to select droppable columns', async () => {
const classified = await getMetadataExportColumns(pg, [
{ name: 'created_at', type: 'timestamptz', columnDefault: 'now()' },
{ name: 'updated_at', type: 'timestamptz', columnDefault: 'now()' },
{ name: 'expires_at', type: 'timestamptz', columnDefault: null },
{ name: 'dbname', type: 'text', columnDefault: 'current_database()' },
{ name: 'fixed_at', type: 'timestamptz', columnDefault: "'2020-01-01'::timestamptz" }
]);
const dropped = classified.filter(isVolatileTimestampColumn).map(c => c.name).sort();
expect(dropped).toEqual(['created_at', 'updated_at']);
});

it('preserves input order and returns [] for no columns, cleaning up temp tables', async () => {
expect(await getMetadataExportColumns(pg, [])).toEqual([]);

const order = await getMetadataExportColumns(pg, [
{ name: 'z', type: 'text', columnDefault: null },
{ name: 'a', type: 'timestamptz', columnDefault: 'now()' },
{ name: 'm', type: 'integer', columnDefault: '0' }
]);
expect(order.map(c => c.name)).toEqual(['z', 'a', 'm']);

// No leftover temp relations from the ephemeral tables
const leftover = await pg.query(
`SELECT count(*)::int AS n FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = pg_my_temp_schema()::regnamespace::text
AND c.relname = 'pgpm_metadata_export_columns'`
);
expect(leftover.rows[0].n).toBe(0);
});

it('rolls back cleanly on failure: no leftover temp table, session still usable', async () => {
await expect(
getMetadataExportColumns(pg, [
{ name: 'bad', type: 'timestamptz', columnDefault: 'not_a_function(' }
])
).rejects.toThrow();

// The rollback erased the temp table even though classification failed...
const leftover = await pg.query(
`SELECT count(*)::int AS n FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = pg_my_temp_schema()::regnamespace::text
AND c.relname = 'pgpm_metadata_export_columns'`
);
expect(leftover.rows[0].n).toBe(0);

// ...and the session/transaction is not aborted: further work succeeds.
const ok = await getMetadataExportColumns(pg, [
{ name: 'created_at', type: 'timestamptz', columnDefault: 'now()' }
]);
expect(ok[0].volatileDefault).toBe(true);
});
});

describe('exportTableData', () => {
it('emits a deterministic INSERT ordered by id, excluding volatile timestamps and excludeColumns', async () => {
const result = await exportTableData(
Expand Down
93 changes: 93 additions & 0 deletions pgpm/export/src/export-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,99 @@ export const getDataExportColumns = async (
export const isVolatileTimestampColumn = (column: DataExportColumn): boolean =>
column.volatileDefault && /^timestamp/.test(column.type);

/**
* A column described by metadata (its name, SQL type, and default expression
* text) rather than by a live physical table. Fed to getMetadataExportColumns
* so metadata-only planes (tables that exist as schema metadata but are not
* physically deployed at export time) get the same volatility classification
* as physical tables.
*/
export interface MetadataExportColumn {
/** Column name. */
name: string;
/** SQL type, e.g. 'timestamptz', 'text', 'uuid' — must resolve in the DB. */
type: string;
/** The column's default expression text, if any (e.g. 'now()'). */
columnDefault: string | null;
}

/**
* Classify columns described by metadata instead of a live physical table.
*
* Metadata-only planes describe their tables as schema metadata but are not
* physically deployed when the export runs, so pg_attrdef-based introspection
* cannot see them. This builds an ephemeral temp table from the supplied
* descriptions and runs the exact same provolatile-based classification that
* getDataExportColumns uses for physical tables — one shared code path — so
* volatile-default detection (isVolatileTimestampColumn, etc.) behaves
* identically for both.
*
* Results preserve the input order and names. Each column's type must be a
* type resolvable in the connected database; each non-null default must be a
* valid default expression for that type. `db` must be a single session
* (client), not a pool: the classification runs inside a transaction (or a
* savepoint when the caller is already in one) that is always rolled back, so
* the temp table never survives — not even on failure — and nothing is ever
* committed.
*/
export const getMetadataExportColumns = async (
db: Queryable,
columns: MetadataExportColumn[]
): Promise<DataExportColumn[]> => {
if (columns.length === 0) return [];

const tempTable = 'pgpm_metadata_export_columns';
// Synthetic column identifiers keep the DDL immune to reserved words and
// duplicate names; results map back to the caller's columns by ordinal.
const columnDefs = columns
.map((c, i) => {
const def =
c.columnDefault != null && c.columnDefault !== ''
? ` DEFAULT ${c.columnDefault}`
: '';
return `c${i} ${c.type}${def}`;
})
.join(', ');

// Savepoint when already inside a caller transaction, otherwise our own
// transaction. Either way the work is rolled back below, so the temp table
// name is deterministic and can never collide or leak.
let usedSavepoint = true;
try {
await db.query(`SAVEPOINT ${tempTable}`);
} catch {
usedSavepoint = false;
await db.query('BEGIN');
}
try {
await db.query(`CREATE TEMP TABLE ${tempTable} (${columnDefs})`);
const tempSchemaRes = await db.query(
`SELECT nspname FROM pg_namespace WHERE oid = pg_my_temp_schema()`
);
const tempSchema = tempSchemaRes.rows[0]?.nspname as string;
const introspected = await getDataExportColumns(db, tempSchema, tempTable);

// getDataExportColumns orders by attnum, matching the c0..cN creation
// order, so results align with the input columns by index.
return columns.map((c, i) => {
const found = introspected[i];
return {
name: c.name,
type: found ? found.type : c.type,
columnDefault: c.columnDefault ?? null,
volatileDefault: found ? found.volatileDefault : false
};
});
} finally {
if (usedSavepoint) {
await db.query(`ROLLBACK TO SAVEPOINT ${tempTable}`);
await db.query(`RELEASE SAVEPOINT ${tempTable}`);
} else {
await db.query('ROLLBACK');
}
}
};

export interface DataExportTableSpec {
schema: string;
table: string;
Expand Down
Loading