mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-13 08:15:14 +02:00
feat: add schema-first database scope picker
This commit is contained in:
parent
4efa061c0e
commit
8e17bf25af
4 changed files with 330 additions and 139 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
pickDatabaseScope,
|
||||
type DatabaseScopePromptAdapter,
|
||||
type DatabaseTreePickerRenderer,
|
||||
type PickDatabaseScopeArgs,
|
||||
} from './database-tree-picker.js';
|
||||
|
|
@ -12,8 +13,17 @@ function makeIo() {
|
|||
let stderr = '';
|
||||
return {
|
||||
io: {
|
||||
stdout: { isTTY: true, write: (chunk: string) => { stdout += chunk; } },
|
||||
stderr: { write: (chunk: string) => { stderr += chunk; } },
|
||||
stdout: {
|
||||
isTTY: true,
|
||||
write: (chunk: string) => {
|
||||
stdout += chunk;
|
||||
},
|
||||
},
|
||||
stderr: {
|
||||
write: (chunk: string) => {
|
||||
stderr += chunk;
|
||||
},
|
||||
},
|
||||
},
|
||||
stdout: () => stdout,
|
||||
stderr: () => stderr,
|
||||
|
|
@ -48,23 +58,96 @@ const discovered = [
|
|||
{ schema: 'public', name: 'sessions', kind: 'table' as const },
|
||||
];
|
||||
|
||||
function promptAdapter(overrides: Partial<DatabaseScopePromptAdapter> = {}): DatabaseScopePromptAdapter {
|
||||
return {
|
||||
autocompleteMultiselect: vi.fn(async () => ['analytics']),
|
||||
select: vi.fn(async () => 'refine'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function baseArgs(overrides: Partial<PickDatabaseScopeArgs> = {}): PickDatabaseScopeArgs {
|
||||
return {
|
||||
connectionId: 'warehouse',
|
||||
schemaNoun: 'schema',
|
||||
schemaNounPlural: 'schemas',
|
||||
discovered,
|
||||
schemas: ['analytics', 'public'],
|
||||
schemaSuggestion: { excluded: new Set(), suggested: new Set(['analytics']) },
|
||||
existing: { enabledTables: [] },
|
||||
defaultSchemas: ['analytics'],
|
||||
supportsSchemaScope: true,
|
||||
listTablesForSchemas: vi.fn(async () => discovered),
|
||||
prompts: promptAdapter(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('pickDatabaseScope', () => {
|
||||
it('starts Stage 1 with no checked schemas and does not enumerate tables before schema selection', async () => {
|
||||
const prompts = promptAdapter({
|
||||
autocompleteMultiselect: vi.fn(async () => ['analytics']),
|
||||
select: vi.fn(async () => 'save'),
|
||||
});
|
||||
const listTablesForSchemas = vi.fn(async () => [
|
||||
{ schema: 'analytics', name: 'orders', kind: 'table' as const },
|
||||
]);
|
||||
|
||||
const result = await pickDatabaseScope(
|
||||
baseArgs({
|
||||
connectionId: 'warehouse',
|
||||
schemaNoun: 'dataset',
|
||||
schemaNounPlural: 'datasets',
|
||||
schemas: ['analytics', 'raw'],
|
||||
schemaSuggestion: { excluded: new Set(['raw']), suggested: new Set(['analytics']) },
|
||||
listTablesForSchemas,
|
||||
prompts,
|
||||
}),
|
||||
makeIo().io,
|
||||
captureRenderer().renderer,
|
||||
);
|
||||
|
||||
expect(listTablesForSchemas).toHaveBeenCalledTimes(1);
|
||||
expect(listTablesForSchemas).toHaveBeenCalledWith(['analytics']);
|
||||
expect(result).toEqual({
|
||||
kind: 'selected',
|
||||
activeSchemas: ['analytics'],
|
||||
enabledTables: ['analytics.orders'],
|
||||
});
|
||||
});
|
||||
|
||||
it('routes partial existing allowlists through Stage 2 so save preserves table selections', async () => {
|
||||
const { renderer, setResult } = captureRenderer();
|
||||
setResult({ kind: 'save', selectedIds: ['analytics.customers'] });
|
||||
const prompts = promptAdapter({
|
||||
autocompleteMultiselect: vi.fn(async () => ['analytics']),
|
||||
select: vi.fn(async () => 'save'),
|
||||
});
|
||||
const listTablesForSchemas = vi.fn(async () => [
|
||||
{ schema: 'analytics', name: 'customers', kind: 'table' as const },
|
||||
{ schema: 'analytics', name: 'orders', kind: 'table' as const },
|
||||
]);
|
||||
|
||||
const result = await pickDatabaseScope(
|
||||
baseArgs({
|
||||
schemas: ['analytics'],
|
||||
schemaSuggestion: { excluded: new Set(), suggested: new Set(['analytics']) },
|
||||
existing: { enabledTables: ['analytics.customers'] },
|
||||
listTablesForSchemas,
|
||||
prompts,
|
||||
}),
|
||||
makeIo().io,
|
||||
renderer,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'selected',
|
||||
activeSchemas: ['analytics'],
|
||||
enabledTables: ['analytics.customers'],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a 2-level tree (schemas as parents, tables as children) and uses save-empty action', async () => {
|
||||
const { renderer, capture, setResult } = captureRenderer();
|
||||
setResult({ kind: 'quit' });
|
||||
setResult({ kind: 'save', selectedIds: ['analytics'] });
|
||||
|
||||
await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
|
||||
|
||||
|
|
@ -81,18 +164,18 @@ describe('pickDatabaseScope', () => {
|
|||
expect(capture.state?.byId.get('public.events')?.title).toBe('events (view)');
|
||||
});
|
||||
|
||||
it('pre-checks default schemas at the parent level when no existing selection', async () => {
|
||||
it('pre-checks selected schemas at the parent level when no existing selection reaches Stage 2', async () => {
|
||||
const { renderer, capture, setResult } = captureRenderer();
|
||||
setResult({ kind: 'quit' });
|
||||
setResult({ kind: 'save', selectedIds: ['analytics'] });
|
||||
|
||||
await pickDatabaseScope(baseArgs({ defaultSchemas: ['analytics'] }), makeIo().io, renderer);
|
||||
await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
|
||||
|
||||
expect([...(capture.state?.checked ?? [])]).toEqual(['analytics']);
|
||||
});
|
||||
|
||||
it('collapses an existing full-schema selection back into the parent check', async () => {
|
||||
const { renderer, capture, setResult } = captureRenderer();
|
||||
setResult({ kind: 'quit' });
|
||||
setResult({ kind: 'save', selectedIds: ['analytics'] });
|
||||
|
||||
await pickDatabaseScope(
|
||||
baseArgs({ existing: { enabledTables: ['analytics.customers', 'analytics.orders'] } }),
|
||||
|
|
@ -105,7 +188,7 @@ describe('pickDatabaseScope', () => {
|
|||
|
||||
it('keeps a partial existing selection at the leaf level', async () => {
|
||||
const { renderer, capture, setResult } = captureRenderer();
|
||||
setResult({ kind: 'quit' });
|
||||
setResult({ kind: 'save', selectedIds: ['analytics.customers'] });
|
||||
|
||||
await pickDatabaseScope(
|
||||
baseArgs({ existing: { enabledTables: ['analytics.customers'] } }),
|
||||
|
|
@ -142,24 +225,6 @@ describe('pickDatabaseScope', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('treats empty save as enable-all', async () => {
|
||||
const { renderer, setResult } = captureRenderer();
|
||||
setResult({ kind: 'save', selectedIds: [] });
|
||||
|
||||
const result = await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'selected',
|
||||
activeSchemas: ['analytics', 'public'],
|
||||
enabledTables: [
|
||||
'analytics.customers',
|
||||
'analytics.orders',
|
||||
'public.events',
|
||||
'public.sessions',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits activeSchemas when the driver does not support a schema scope', async () => {
|
||||
const { renderer, setResult } = captureRenderer();
|
||||
setResult({ kind: 'save', selectedIds: ['analytics'] });
|
||||
|
|
@ -177,11 +242,12 @@ describe('pickDatabaseScope', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('returns back when the picker quits', async () => {
|
||||
const { renderer, setResult } = captureRenderer();
|
||||
setResult({ kind: 'quit' });
|
||||
it('returns back when Stage 1 is cancelled', async () => {
|
||||
const prompts = promptAdapter({
|
||||
autocompleteMultiselect: vi.fn(async () => ['back']),
|
||||
});
|
||||
|
||||
const result = await pickDatabaseScope(baseArgs(), makeIo().io, renderer);
|
||||
const result = await pickDatabaseScope(baseArgs({ prompts }), makeIo().io, captureRenderer().renderer);
|
||||
|
||||
expect(result).toEqual({ kind: 'back' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,15 +43,33 @@ interface ScopeSuggestion {
|
|||
suggested: Set<string>;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface DatabaseScopePromptAdapter {
|
||||
autocompleteMultiselect(options: {
|
||||
message: string;
|
||||
options: Array<{ value: string; label: string; hint?: string; disabled?: boolean }>;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
maxItems?: number;
|
||||
initialValues?: string[];
|
||||
}): Promise<string[]>;
|
||||
select(options: {
|
||||
message: string;
|
||||
options: Array<{ value: string; label: string; hint?: string; disabled?: boolean }>;
|
||||
}): Promise<string>;
|
||||
}
|
||||
|
||||
export interface PickDatabaseScopeArgs {
|
||||
connectionId: string;
|
||||
schemaNoun: string;
|
||||
schemaNounPlural: string;
|
||||
discovered: readonly KtxTableListEntry[];
|
||||
schemas: readonly string[];
|
||||
schemaSuggestion: ScopeSuggestion;
|
||||
existing: { enabledTables: readonly string[] };
|
||||
defaultSchemas: readonly string[];
|
||||
schemaSuggestion?: ScopeSuggestion;
|
||||
supportsSchemaScope: boolean;
|
||||
listTablesForSchemas: (schemas: string[]) => Promise<KtxTableListEntry[]>;
|
||||
initialSchemas?: readonly string[];
|
||||
prompts: DatabaseScopePromptAdapter;
|
||||
}
|
||||
|
||||
function qualifiedTableId(entry: KtxTableListEntry): string {
|
||||
|
|
@ -167,12 +185,39 @@ function schemasFromEnabledTables(enabledTables: readonly string[]): string[] {
|
|||
return result;
|
||||
}
|
||||
|
||||
export async function pickDatabaseScope(
|
||||
args: PickDatabaseScopeArgs,
|
||||
io: KtxCliIo,
|
||||
render: DatabaseTreePickerRenderer = defaultRenderer,
|
||||
): Promise<DatabaseScopePickResult> {
|
||||
const { inputs, schemaIds, allTables } = buildTreeInputs(args.discovered);
|
||||
function schemaOptions(args: PickDatabaseScopeArgs): Array<{ value: string; label: string; hint?: string }> {
|
||||
return args.schemas
|
||||
.filter((schema) => !args.schemaSuggestion.excluded.has(schema))
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftSuggested = args.schemaSuggestion.suggested.has(left);
|
||||
const rightSuggested = args.schemaSuggestion.suggested.has(right);
|
||||
if (leftSuggested !== rightSuggested) return leftSuggested ? -1 : 1;
|
||||
return left.localeCompare(right);
|
||||
})
|
||||
.map((schema) => ({
|
||||
value: schema,
|
||||
label: schema,
|
||||
...(args.schemaSuggestion.suggested.has(schema) ? { hint: 'suggested' } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
function initialStageOneSchemas(args: PickDatabaseScopeArgs): string[] {
|
||||
if (args.existing.enabledTables.length > 0) {
|
||||
return schemasFromEnabledTables(args.existing.enabledTables);
|
||||
}
|
||||
return [...(args.initialSchemas ?? [])];
|
||||
}
|
||||
|
||||
async function runStageTwoTreePicker(input: {
|
||||
args: PickDatabaseScopeArgs;
|
||||
discovered: readonly KtxTableListEntry[];
|
||||
selectedSchemas: readonly string[];
|
||||
io: KtxCliIo;
|
||||
render: DatabaseTreePickerRenderer;
|
||||
}): Promise<DatabaseScopePickResult> {
|
||||
const { args, discovered, selectedSchemas, io, render } = input;
|
||||
const { inputs, schemaIds, allTables } = buildTreeInputs(discovered);
|
||||
const tree = buildPickerTree(inputs);
|
||||
const byId = new Map(tree.map((node) => [node.id, node]));
|
||||
const tableCount = allTables.length;
|
||||
|
|
@ -181,7 +226,7 @@ export async function pickDatabaseScope(
|
|||
const initialSelection =
|
||||
args.existing.enabledTables.length > 0
|
||||
? initialSelectionForExisting(args.existing.enabledTables, byId)
|
||||
: initialSelectionFromDefaults(args.defaultSchemas, schemaIds);
|
||||
: initialSelectionFromDefaults(selectedSchemas, schemaIds);
|
||||
|
||||
const initialState = buildInitialState({
|
||||
tree,
|
||||
|
|
@ -214,3 +259,61 @@ export async function pickDatabaseScope(
|
|||
|
||||
return { kind: 'selected', activeSchemas, enabledTables };
|
||||
}
|
||||
|
||||
export async function pickDatabaseScope(
|
||||
args: PickDatabaseScopeArgs,
|
||||
io: KtxCliIo,
|
||||
render: DatabaseTreePickerRenderer = defaultRenderer,
|
||||
): Promise<DatabaseScopePickResult> {
|
||||
let selectedSchemas = initialStageOneSchemas(args);
|
||||
while (true) {
|
||||
const pickedSchemas = await args.prompts.autocompleteMultiselect({
|
||||
message: `Choose ${args.schemaNounPlural} to enable for ${args.connectionId}\nType to filter. Space to select. Enter when done.`,
|
||||
placeholder: `Search ${args.schemaNounPlural}`,
|
||||
options: schemaOptions(args),
|
||||
initialValues: selectedSchemas,
|
||||
required: false,
|
||||
});
|
||||
if (pickedSchemas.includes('back')) {
|
||||
return { kind: 'back' };
|
||||
}
|
||||
selectedSchemas = pickedSchemas;
|
||||
if (selectedSchemas.length === 0) {
|
||||
io.stderr.write(`Nothing selected - type to filter, or Escape to skip ${args.schemaNoun} scope.\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const action = await args.prompts.select({
|
||||
message: `Save ${selectedSchemas.length} ${selectedSchemas.length === 1 ? args.schemaNoun : args.schemaNounPlural} or refine tables?`,
|
||||
options: [
|
||||
{ value: 'save', label: 'Save selection' },
|
||||
{ value: 'refine', label: 'Refine: choose individual tables' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
],
|
||||
});
|
||||
if (action === 'back') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const discovered = await args.listTablesForSchemas(selectedSchemas);
|
||||
if (action === 'save' && args.existing.enabledTables.length === 0) {
|
||||
return {
|
||||
kind: 'selected',
|
||||
activeSchemas: args.supportsSchemaScope ? selectedSchemas : [],
|
||||
enabledTables: discovered.map(qualifiedTableId),
|
||||
};
|
||||
}
|
||||
|
||||
const refined = await runStageTwoTreePicker({
|
||||
args,
|
||||
discovered,
|
||||
selectedSchemas,
|
||||
io,
|
||||
render,
|
||||
});
|
||||
if (refined.kind === 'back') {
|
||||
continue;
|
||||
}
|
||||
return refined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ function makeIo() {
|
|||
type ScopePick =
|
||||
| 'back'
|
||||
| 'enable-all'
|
||||
| { schemas: string[]; tables: string[] };
|
||||
| { schemas: string[]; tables: string[] | 'back' };
|
||||
|
||||
interface PickerStubs {
|
||||
pickDatabaseScope: KtxSetupDatabasesDeps['pickDatabaseScope'];
|
||||
|
|
@ -58,15 +58,21 @@ function makePickerStubs(options: { scopes?: ScopePick[] } = {}): PickerStubs {
|
|||
scopeCalls.push(args);
|
||||
const next = queue.shift();
|
||||
if (next === undefined || next === 'enable-all') {
|
||||
const enabledTables = args.discovered.map((t) => `${t.schema}.${t.name}`);
|
||||
const schemas = args.initialSchemas && args.initialSchemas.length > 0 ? [...args.initialSchemas] : [...args.schemas];
|
||||
const discovered = await args.listTablesForSchemas(schemas);
|
||||
const enabledTables = discovered.map((t) => `${t.schema}.${t.name}`);
|
||||
const activeSchemas = args.supportsSchemaScope
|
||||
? Array.from(new Set(args.discovered.map((t) => t.schema)))
|
||||
? Array.from(new Set(discovered.map((t) => t.schema)))
|
||||
: [];
|
||||
return { kind: 'selected', activeSchemas, enabledTables };
|
||||
}
|
||||
if (next === 'back') {
|
||||
return { kind: 'back' };
|
||||
}
|
||||
await args.listTablesForSchemas(next.schemas);
|
||||
if (next.tables === 'back') {
|
||||
return { kind: 'back' };
|
||||
}
|
||||
return {
|
||||
kind: 'selected',
|
||||
activeSchemas: args.supportsSchemaScope ? next.schemas : [],
|
||||
|
|
@ -88,7 +94,19 @@ function makePromptAdapter(options: {
|
|||
const passwordValues = [...(options.passwordValues ?? [])];
|
||||
return {
|
||||
multiselect: vi.fn(async () => multiselectValues.shift() ?? ['postgres']),
|
||||
autocompleteMultiselect: vi.fn(async (options) => {
|
||||
if (multiselectValues.length > 0) {
|
||||
return multiselectValues.shift() ?? [];
|
||||
}
|
||||
if (options.initialValues && options.initialValues.length > 0) {
|
||||
return options.initialValues;
|
||||
}
|
||||
return options.options.length > 0 ? options.options.map((option) => option.value) : ['back'];
|
||||
}),
|
||||
select: vi.fn(async ({ message }) => {
|
||||
if (message.startsWith('Save ') && message.includes(' or refine tables?')) {
|
||||
return 'save';
|
||||
}
|
||||
if (message.includes('How much database context should KTX build?')) {
|
||||
const nextValue = selectValues[0];
|
||||
return nextValue === 'fast' || nextValue === 'deep' || nextValue === 'back'
|
||||
|
|
@ -915,7 +933,7 @@ describe('setup databases step', () => {
|
|||
placeholder: 'env:DATABASE_URL',
|
||||
initialValue: 'env:DATABASE_URL',
|
||||
});
|
||||
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse', ['analytics', 'public']);
|
||||
expect(listTables).toHaveBeenCalledWith(tempDir, 'warehouse', ['analytics']);
|
||||
expect(testConnection).toHaveBeenCalledWith(tempDir, 'warehouse', expect.anything());
|
||||
expect(scanConnection).toHaveBeenCalledWith(tempDir, 'warehouse', expect.anything());
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
|
|
@ -1105,7 +1123,7 @@ describe('setup databases step', () => {
|
|||
{ schema: 'public', name: 'customers', kind: 'table' as const },
|
||||
{ schema: 'public', name: 'orders', kind: 'table' as const },
|
||||
]);
|
||||
const pickers = makePickerStubs({ scopes: ['back'] });
|
||||
const pickers = makePickerStubs({ scopes: [{ schemas: ['public'], tables: 'back' }] });
|
||||
|
||||
const result = await runKtxSetupDatabasesStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', skipDatabases: false, databaseSchemas: [] },
|
||||
|
|
@ -1469,7 +1487,7 @@ describe('setup databases step', () => {
|
|||
schemaSuggestion: { suggested: Set<string> };
|
||||
};
|
||||
expect(args.schemaNoun).toBe('database');
|
||||
expect(args.discovered.map((table) => table.schema)).toEqual(['analytics', 'mart']);
|
||||
expect(args.schemas).toEqual(['analytics', 'mart']);
|
||||
expect(scopedArgs.schemaSuggestion.suggested).toEqual(new Set(['analytics', 'mart']));
|
||||
return { kind: 'selected' as const, activeSchemas: ['mart'], enabledTables: ['mart.orders'] };
|
||||
});
|
||||
|
|
@ -1591,7 +1609,8 @@ describe('setup databases step', () => {
|
|||
connectionId: 'postgres-warehouse',
|
||||
schemaNoun: 'schema',
|
||||
schemaNounPlural: 'schemas',
|
||||
defaultSchemas: ['orbit_analytics', 'orbit_raw'],
|
||||
schemas: ['orbit_analytics', 'orbit_raw', 'public'],
|
||||
schemaSuggestion: { excluded: new Set(), suggested: new Set() },
|
||||
});
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
expect(config.connections['postgres-warehouse']).toMatchObject({
|
||||
|
|
@ -1600,6 +1619,41 @@ describe('setup databases step', () => {
|
|||
expect(io.stdout()).toContain('✓ orbit_analytics, orbit_raw');
|
||||
});
|
||||
|
||||
it('passes schemas and a lazy table callback to the scope picker instead of eager table discovery', async () => {
|
||||
const listSchemas = vi.fn(async () => ['analytics', 'raw']);
|
||||
const listTables = vi.fn(async (_projectDir: string, _connectionId: string, schemas?: string[]) =>
|
||||
(schemas ?? []).map((schema) => ({ schema, name: 'orders', kind: 'table' as const })),
|
||||
);
|
||||
const pickDatabaseScope = vi.fn(async (args: PickDatabaseScopeArgs) => {
|
||||
const lazyArgs = args as PickDatabaseScopeArgs & {
|
||||
schemas: string[];
|
||||
listTablesForSchemas: (schemas: string[]) => Promise<Array<{ schema: string; name: string; kind: 'table' }>>;
|
||||
};
|
||||
expect(lazyArgs.schemas).toEqual(['analytics', 'raw']);
|
||||
expect(args).not.toHaveProperty('discovered');
|
||||
expect(listTables).not.toHaveBeenCalled();
|
||||
const tables = await lazyArgs.listTablesForSchemas(['analytics']);
|
||||
expect(tables).toEqual([{ schema: 'analytics', name: 'orders', kind: 'table' }]);
|
||||
return { kind: 'selected' as const, activeSchemas: ['analytics'], enabledTables: ['analytics.orders'] };
|
||||
});
|
||||
|
||||
await runKtxSetupDatabasesStep(
|
||||
{ projectDir: tempDir, inputMode: 'auto', databaseDrivers: ['postgres'], skipDatabases: false, databaseSchemas: [] },
|
||||
makeIo().io,
|
||||
{
|
||||
prompts: makePromptAdapter({ selectValues: ['url'], textValues: ['', 'env:DATABASE_URL'] }),
|
||||
testConnection: vi.fn(async () => 0),
|
||||
scanConnection: vi.fn(async () => 0),
|
||||
listSchemas,
|
||||
listTables,
|
||||
pickDatabaseScope,
|
||||
},
|
||||
);
|
||||
|
||||
expect(listTables).toHaveBeenCalledTimes(1);
|
||||
expect(listTables).toHaveBeenCalledWith(tempDir, 'postgres-warehouse', ['analytics']);
|
||||
});
|
||||
|
||||
it('auto-selects all discovered Postgres schemas in non-interactive setup', async () => {
|
||||
const io = makeIo();
|
||||
const prompts = makePromptAdapter({});
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ export interface KtxSetupDatabasesPromptAdapter {
|
|||
initialValues?: string[];
|
||||
}): Promise<string[]>;
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
autocompleteMultiselect(options: {
|
||||
message: string;
|
||||
options: KtxSetupPromptOption[];
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
maxItems?: number;
|
||||
initialValues?: string[];
|
||||
}): Promise<string[]>;
|
||||
text(options: { message: string; placeholder?: string; initialValue?: string }): Promise<string | undefined>;
|
||||
password(options: { message: string }): Promise<string | undefined>;
|
||||
cancel(message: string): void;
|
||||
|
|
@ -156,19 +164,6 @@ function defaultSuggest(values: string[]): ScopeSuggestion {
|
|||
return { excluded, suggested };
|
||||
}
|
||||
|
||||
function legacyDefaultSchemasForPicker(
|
||||
schemas: string[],
|
||||
suggestion: ScopeSuggestion,
|
||||
): string[] {
|
||||
const suggested = schemas.filter((schema) => suggestion.suggested.has(schema));
|
||||
if (suggested.length > 0) {
|
||||
return suggested;
|
||||
}
|
||||
const visible = schemas.filter((schema) => !suggestion.excluded.has(schema));
|
||||
const nonPublic = visible.filter((schema) => schema !== 'public' && schema !== 'PUBLIC');
|
||||
return nonPublic.length > 0 ? nonPublic : visible;
|
||||
}
|
||||
|
||||
const SCOPE_DISCOVERY_SPECS: Partial<Record<KtxSetupDatabaseDriver, ScopeDiscoverySpec>> = {
|
||||
postgres: {
|
||||
noun: 'schema',
|
||||
|
|
@ -1413,6 +1408,7 @@ async function maybeConfigureDatabaseScope(input: {
|
|||
args: KtxSetupDatabasesArgs;
|
||||
deps: KtxSetupDatabasesDeps;
|
||||
io: KtxCliIo;
|
||||
prompts: KtxSetupDatabasesPromptAdapter;
|
||||
forcePrompt?: boolean;
|
||||
}): Promise<ConnectionSetupStatus> {
|
||||
const project = await loadKtxProject({ projectDir: input.projectDir });
|
||||
|
|
@ -1473,32 +1469,53 @@ async function maybeConfigureDatabaseScope(input: {
|
|||
});
|
||||
}
|
||||
|
||||
writeSetupSection(input.io, 'Discovering tables', [
|
||||
`Connecting to ${input.connectionId}…`,
|
||||
]);
|
||||
writeSetupSection(input.io, 'Discovering tables', [`Connecting to ${input.connectionId}…`]);
|
||||
|
||||
const schemasFilter = await (async (): Promise<string[]> => {
|
||||
if (cliSchemas.length > 0) return cliSchemas;
|
||||
if (!spec) return [];
|
||||
try {
|
||||
return unique(
|
||||
await (input.deps.listSchemas ?? defaultListSchemas)(input.projectDir, input.connectionId),
|
||||
);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
input.io.stderr.write(
|
||||
`Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; ${detail}\n`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const schemas = unique(
|
||||
cliSchemas.length > 0
|
||||
? cliSchemas
|
||||
: await (async (): Promise<string[]> => {
|
||||
if (!spec) return [];
|
||||
try {
|
||||
return await (input.deps.listSchemas ?? defaultListSchemas)(input.projectDir, input.connectionId);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
input.io.stderr.write(
|
||||
`Could not discover ${spec.promptLabel.toLowerCase()} for ${input.connectionId}; ${detail}\n`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
);
|
||||
if (spec && schemas.length === 0) {
|
||||
return 'ready';
|
||||
}
|
||||
const schemaSuggestion =
|
||||
cliSchemas.length > 0
|
||||
? { excluded: new Set<string>(), suggested: new Set(cliSchemas) }
|
||||
: spec?.suggest(schemas) ?? { excluded: new Set<string>(), suggested: new Set<string>() };
|
||||
const existingEnabled =
|
||||
hasExistingTables && input.forcePrompt === true
|
||||
? (existingTables ?? []).filter((table): table is string => typeof table === 'string')
|
||||
: [];
|
||||
|
||||
let discovered: KtxTableListEntry[];
|
||||
let pickResult: DatabaseScopePickResult;
|
||||
try {
|
||||
discovered = await (input.deps.listTables ?? defaultListTables)(
|
||||
input.projectDir,
|
||||
input.connectionId,
|
||||
schemasFilter.length > 0 ? schemasFilter : undefined,
|
||||
pickResult = await (input.deps.pickDatabaseScope ?? defaultPickDatabaseScope)(
|
||||
{
|
||||
connectionId: input.connectionId,
|
||||
schemaNoun: spec?.noun ?? 'schema',
|
||||
schemaNounPlural: spec?.nounPlural ?? 'schemas',
|
||||
schemas,
|
||||
schemaSuggestion,
|
||||
existing: { enabledTables: existingEnabled },
|
||||
supportsSchemaScope: spec !== undefined,
|
||||
initialSchemas: cliSchemas.length > 0 ? cliSchemas : undefined,
|
||||
prompts: input.prompts,
|
||||
listTablesForSchemas: (selectedSchemas) =>
|
||||
(input.deps.listTables ?? defaultListTables)(input.projectDir, input.connectionId, selectedSchemas),
|
||||
},
|
||||
input.io,
|
||||
);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
|
|
@ -1509,60 +1526,11 @@ async function maybeConfigureDatabaseScope(input: {
|
|||
);
|
||||
return input.forcePrompt === true ? 'failed' : 'ready';
|
||||
}
|
||||
|
||||
if (discovered.length === 0) {
|
||||
if (input.forcePrompt === true) {
|
||||
input.io.stderr.write(`No tables discovered for ${input.connectionId}; edit was not saved.\n`);
|
||||
}
|
||||
return input.forcePrompt === true ? 'failed' : 'ready';
|
||||
}
|
||||
|
||||
const allQualified = discovered.map((t) => `${t.schema}.${t.name}`);
|
||||
const schemasInDiscovery = unique(discovered.map((t) => t.schema));
|
||||
|
||||
const defaultSchemas = (() => {
|
||||
if (cliSchemas.length > 0) return cliSchemas;
|
||||
if (!spec) return schemasInDiscovery;
|
||||
const suggestion = spec.suggest(schemasInDiscovery);
|
||||
return legacyDefaultSchemasForPicker(schemasInDiscovery, suggestion);
|
||||
})();
|
||||
const schemaSuggestion = cliSchemas.length > 0
|
||||
? { excluded: new Set<string>(), suggested: new Set(cliSchemas) }
|
||||
: spec?.suggest(schemasInDiscovery);
|
||||
|
||||
const existingEnabled =
|
||||
hasExistingTables && input.forcePrompt === true
|
||||
? (existingTables ?? []).filter(
|
||||
(table): table is string => typeof table === 'string' && allQualified.includes(table),
|
||||
)
|
||||
: [];
|
||||
|
||||
let activeSchemas: string[];
|
||||
let enabledTables: string[];
|
||||
|
||||
if (discovered.length === 1) {
|
||||
enabledTables = allQualified;
|
||||
activeSchemas = spec ? schemasInDiscovery : [];
|
||||
} else {
|
||||
const pickResult = await (input.deps.pickDatabaseScope ?? defaultPickDatabaseScope)(
|
||||
{
|
||||
connectionId: input.connectionId,
|
||||
schemaNoun: spec?.noun ?? 'schema',
|
||||
schemaNounPlural: spec?.nounPlural ?? 'schemas',
|
||||
discovered,
|
||||
existing: { enabledTables: existingEnabled },
|
||||
defaultSchemas,
|
||||
...(schemaSuggestion ? { schemaSuggestion } : {}),
|
||||
supportsSchemaScope: spec !== undefined,
|
||||
},
|
||||
input.io,
|
||||
);
|
||||
if (pickResult.kind === 'back') {
|
||||
return 'back';
|
||||
}
|
||||
enabledTables = pickResult.enabledTables;
|
||||
activeSchemas = pickResult.activeSchemas;
|
||||
if (pickResult.kind === 'back') {
|
||||
return 'back';
|
||||
}
|
||||
const enabledTables = pickResult.enabledTables;
|
||||
const activeSchemas = pickResult.activeSchemas;
|
||||
|
||||
if (spec) {
|
||||
await writeScopeConfig({
|
||||
|
|
@ -1588,7 +1556,7 @@ async function maybeConfigureDatabaseScope(input: {
|
|||
]);
|
||||
}
|
||||
writeSetupSection(input.io, `Tables enabled for ${input.connectionId}`, [
|
||||
`✓ ${enabledTables.length}/${discovered.length} tables enabled`,
|
||||
`✓ ${enabledTables.length} tables enabled`,
|
||||
]);
|
||||
return 'ready';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue