mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
test(scan): cover table-ref helpers
This commit is contained in:
parent
2f651e4dbe
commit
8c81035662
4 changed files with 228 additions and 8 deletions
|
|
@ -1,17 +1,76 @@
|
|||
import type { KtxSchemaSnapshot } from './types.js';
|
||||
import { hasTableRef, tableRefSet, type KtxTableRefKey } from './table-ref.js';
|
||||
import type { KtxSchemaSnapshot, KtxTableRef } from './types.js';
|
||||
|
||||
export function resolveEnabledTables(connection: Record<string, unknown> | undefined): Set<string> | null {
|
||||
/**
|
||||
* Parses the `enabled_tables` field on a connection into a scope of
|
||||
* fully-qualified table refs. Returns `null` when the field is absent or
|
||||
* empty (meaning "no scope — include every table in the resolved schemas").
|
||||
*
|
||||
* Accepted entry forms:
|
||||
* "catalog.db.name" — fully qualified
|
||||
* "db.name" — schema-qualified (catalog = null; legacy / Postgres-shape)
|
||||
* "name" — bare (catalog = db = null; SQLite-shape)
|
||||
* { catalog?, db?, name } — escape hatch for identifiers containing dots
|
||||
*
|
||||
* The setup wizard writes the fully-qualified form going forward; the lenient
|
||||
* parser keeps existing project configs working.
|
||||
*/
|
||||
export function resolveEnabledTables(
|
||||
connection: Record<string, unknown> | undefined,
|
||||
): ReadonlySet<KtxTableRefKey> | null {
|
||||
const raw = connection?.enabled_tables;
|
||||
if (!Array.isArray(raw) || raw.length === 0) return null;
|
||||
return new Set(raw.filter((v): v is string => typeof v === 'string'));
|
||||
const refs: KtxTableRef[] = [];
|
||||
for (const value of raw) {
|
||||
const parsed = parseEnabledTableEntry(value);
|
||||
if (parsed) refs.push(parsed);
|
||||
}
|
||||
if (refs.length === 0) return null;
|
||||
return tableRefSet(refs);
|
||||
}
|
||||
|
||||
export function filterSnapshotTables(snapshot: KtxSchemaSnapshot, enabledTables: Set<string>): KtxSchemaSnapshot {
|
||||
function parseEnabledTableEntry(value: unknown): KtxTableRef | null {
|
||||
if (typeof value === 'string') {
|
||||
return parseDottedEntry(value);
|
||||
}
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const entry = value as { catalog?: unknown; db?: unknown; name?: unknown };
|
||||
const name = typeof entry.name === 'string' ? entry.name : null;
|
||||
if (!name) return null;
|
||||
return {
|
||||
catalog: typeof entry.catalog === 'string' ? entry.catalog : null,
|
||||
db: typeof entry.db === 'string' ? entry.db : null,
|
||||
name,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseDottedEntry(value: string): KtxTableRef | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
const parts = trimmed.split('.');
|
||||
if (parts.length === 3) {
|
||||
return { catalog: parts[0]!, db: parts[1]!, name: parts[2]! };
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { catalog: null, db: parts[0]!, name: parts[1]! };
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return { catalog: null, db: null, name: parts[0]! };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @internal — kept as a defensive backstop for the live-database adapter and tests. */
|
||||
export function filterSnapshotTables(
|
||||
snapshot: KtxSchemaSnapshot,
|
||||
enabledTables: ReadonlySet<KtxTableRefKey>,
|
||||
): KtxSchemaSnapshot {
|
||||
return {
|
||||
...snapshot,
|
||||
tables: snapshot.tables.filter((table) => {
|
||||
const key = table.db ? `${table.db}.${table.name}` : table.name;
|
||||
return enabledTables.has(key);
|
||||
}),
|
||||
tables: snapshot.tables.filter((table) =>
|
||||
hasTableRef(enabledTables, { catalog: table.catalog, db: table.db, name: table.name }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
88
packages/cli/src/context/scan/table-ref.test.ts
Normal file
88
packages/cli/src/context/scan/table-ref.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
hasTableRef,
|
||||
scopedTableNames,
|
||||
tableRefFromKey,
|
||||
tableRefKey,
|
||||
tableRefSet,
|
||||
type KtxTableRefKey,
|
||||
} from './table-ref.js';
|
||||
|
||||
describe('tableRefKey roundtrip', () => {
|
||||
it('encodes and decodes a three-part ref', () => {
|
||||
const ref = { catalog: 'ANALYTICS', db: 'MARTS', name: 'LISTINGS' };
|
||||
expect(tableRefFromKey(tableRefKey(ref))).toEqual(ref);
|
||||
});
|
||||
|
||||
it('treats null catalog/db as the empty segment', () => {
|
||||
const ref = { catalog: null, db: 'public', name: 'users' };
|
||||
expect(tableRefFromKey(tableRefKey(ref))).toEqual(ref);
|
||||
});
|
||||
|
||||
it('roundtrips a bare-name ref', () => {
|
||||
const ref = { catalog: null, db: null, name: 'orders' };
|
||||
expect(tableRefFromKey(tableRefKey(ref))).toEqual(ref);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tableRefSet', () => {
|
||||
it('produces a set with member-equality on canonical keys', () => {
|
||||
const scope = tableRefSet([
|
||||
{ catalog: 'ANALYTICS', db: 'MARTS', name: 'LISTINGS' },
|
||||
{ catalog: 'ANALYTICS', db: 'MARTS', name: 'ITEMS' },
|
||||
]);
|
||||
expect(scope.size).toBe(2);
|
||||
expect(scope.has(tableRefKey({ catalog: 'ANALYTICS', db: 'MARTS', name: 'LISTINGS' }))).toBe(true);
|
||||
expect(scope.has(tableRefKey({ catalog: 'ANALYTICS', db: 'MARTS', name: 'OTHER' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTableRef', () => {
|
||||
const scope = tableRefSet([
|
||||
{ catalog: 'ANALYTICS', db: 'MARTS', name: 'LISTINGS' },
|
||||
{ catalog: null, db: 'public', name: 'users' },
|
||||
]);
|
||||
|
||||
it('matches fully qualified entries exactly', () => {
|
||||
expect(hasTableRef(scope, { catalog: 'ANALYTICS', db: 'MARTS', name: 'LISTINGS' })).toBe(true);
|
||||
});
|
||||
|
||||
it('matches when the scope omits catalog (legacy 2-part entry)', () => {
|
||||
expect(hasTableRef(scope, { catalog: 'PRODUCTION_DB', db: 'public', name: 'users' })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects refs not in the scope', () => {
|
||||
expect(hasTableRef(scope, { catalog: 'ANALYTICS', db: 'STAGING', name: 'LISTINGS' })).toBe(false);
|
||||
expect(hasTableRef(scope, { catalog: null, db: 'public', name: 'orders' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scopedTableNames', () => {
|
||||
it('projects to the requested (catalog, db) namespace', () => {
|
||||
const scope = tableRefSet([
|
||||
{ catalog: 'ANALYTICS', db: 'MARTS', name: 'LISTINGS' },
|
||||
{ catalog: 'ANALYTICS', db: 'MARTS', name: 'ITEMS' },
|
||||
{ catalog: 'ANALYTICS', db: 'STAGING', name: 'LISTINGS' },
|
||||
]);
|
||||
expect(scopedTableNames(scope, { catalog: 'ANALYTICS', db: 'MARTS' }).sort()).toEqual(['ITEMS', 'LISTINGS']);
|
||||
expect(scopedTableNames(scope, { catalog: 'ANALYTICS', db: 'STAGING' })).toEqual(['LISTINGS']);
|
||||
});
|
||||
|
||||
it('treats null in the scope entry as a wildcard for that segment', () => {
|
||||
const scope = tableRefSet([{ catalog: null, db: 'public', name: 'users' }]);
|
||||
expect(scopedTableNames(scope, { catalog: 'any-catalog', db: 'public' })).toEqual(['users']);
|
||||
});
|
||||
|
||||
it('returns empty when no scope entry matches the namespace', () => {
|
||||
const scope = tableRefSet([{ catalog: 'A', db: 'B', name: 'C' }]);
|
||||
expect(scopedTableNames(scope, { catalog: 'X', db: 'Y' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('dedupes when the same name appears under different catalog projections', () => {
|
||||
const scope: ReadonlySet<KtxTableRefKey> = tableRefSet([
|
||||
{ catalog: null, db: 'public', name: 'users' },
|
||||
{ catalog: 'A', db: 'public', name: 'users' },
|
||||
]);
|
||||
expect(scopedTableNames(scope, { catalog: 'A', db: 'public' })).toEqual(['users']);
|
||||
});
|
||||
});
|
||||
62
packages/cli/src/context/scan/table-ref.ts
Normal file
62
packages/cli/src/context/scan/table-ref.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { KtxTableRef } from './types.js';
|
||||
|
||||
/**
|
||||
* Branded canonical string representation of a {@link KtxTableRef}.
|
||||
*
|
||||
* Connectors compare scopes for set membership via these keys instead of the
|
||||
* raw object (JS `Set<object>` uses identity equality, which would be useless
|
||||
* here). Build a key with {@link tableRefKey} and decode with
|
||||
* {@link tableRefFromKey}.
|
||||
*/
|
||||
export type KtxTableRefKey = string & { readonly __brand: 'KtxTableRefKey' };
|
||||
|
||||
const SEPARATOR = '\x1f';
|
||||
|
||||
export function tableRefKey(ref: KtxTableRef): KtxTableRefKey {
|
||||
return `${ref.catalog ?? ''}${SEPARATOR}${ref.db ?? ''}${SEPARATOR}${ref.name}` as KtxTableRefKey;
|
||||
}
|
||||
|
||||
export function tableRefFromKey(key: KtxTableRefKey): KtxTableRef {
|
||||
const [catalog = '', db = '', name = ''] = key.split(SEPARATOR);
|
||||
return {
|
||||
catalog: catalog.length > 0 ? catalog : null,
|
||||
db: db.length > 0 ? db : null,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
export function tableRefSet(refs: readonly KtxTableRef[]): ReadonlySet<KtxTableRefKey> {
|
||||
return new Set(refs.map(tableRefKey));
|
||||
}
|
||||
|
||||
export function hasTableRef(scope: ReadonlySet<KtxTableRefKey>, ref: KtxTableRef): boolean {
|
||||
if (scope.has(tableRefKey(ref))) return true;
|
||||
if (ref.catalog !== null) {
|
||||
if (scope.has(tableRefKey({ ...ref, catalog: null }))) return true;
|
||||
}
|
||||
if (ref.db !== null) {
|
||||
if (scope.has(tableRefKey({ ...ref, db: null }))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the bare table names from a scope that fall within the given
|
||||
* (catalog, db) namespace. `catalog: null` is treated as a wildcard so that
|
||||
* legacy 2-part `"db.name"` entries continue to match. Same for `db: null`.
|
||||
*/
|
||||
export function scopedTableNames(
|
||||
scope: ReadonlySet<KtxTableRefKey>,
|
||||
namespace: { catalog?: string | null; db?: string | null },
|
||||
): string[] {
|
||||
const names = new Set<string>();
|
||||
const wantCatalog = namespace.catalog ?? null;
|
||||
const wantDb = namespace.db ?? null;
|
||||
for (const key of scope) {
|
||||
const ref = tableRefFromKey(key);
|
||||
if (wantCatalog !== null && ref.catalog !== null && ref.catalog !== wantCatalog) continue;
|
||||
if (wantDb !== null && ref.db !== null && ref.db !== wantDb) continue;
|
||||
names.add(ref.name);
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import type { KtxTableRefKey } from './table-ref.js';
|
||||
|
||||
export type KtxConnectionDriver =
|
||||
| 'sqlite'
|
||||
| 'postgres'
|
||||
|
|
@ -137,6 +139,15 @@ export interface KtxScanInput {
|
|||
connectionId: string;
|
||||
driver: KtxConnectionDriver;
|
||||
scope?: KtxSchemaScope;
|
||||
/**
|
||||
* Restricts introspection to a specific set of fully-qualified tables.
|
||||
* `undefined` means "all tables within {@link scope}". Connectors that honor
|
||||
* this field should push the filter into their metadata queries; the
|
||||
* live-database adapter also applies a final filter before writing, so a
|
||||
* connector that ignores `tableScope` will over-fetch but produce correct
|
||||
* output.
|
||||
*/
|
||||
tableScope?: ReadonlySet<KtxTableRefKey>;
|
||||
mode?: KtxScanMode;
|
||||
dryRun?: boolean;
|
||||
detectRelationships?: boolean;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue