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
4 changes: 2 additions & 2 deletions apps/cli/src/commands/migrate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { confirm } from '@inquirer/prompts';
import chalk from 'chalk';
import type { TableDiff } from '@foxschema/core';
import { normalizeTableSchemas, type TableDiff } from '@foxschema/core';
import { ensureSourceTarget, resolveRef } from '../runtime/connectionRef';
import { friendlyError } from '../format/friendlyError';
import { compareModule, connectionModule, filterIndexDiffs, loadScopedTables, migrationModule, parseScope, sqlGenerator } from '../runtime/engine';
Expand Down Expand Up @@ -130,7 +130,7 @@ export async function runMigrate(opts: MigrateOptions): Promise<void> {
try {
const provider = connectionModule.getProvider(tgt.dialect);
if (provider.getTables) {
const objs = await provider.getTables(tgt.option, tgt.schema);
const objs = normalizeTableSchemas(await provider.getTables(tgt.option, tgt.schema));
snapshotDdl =
`-- Target snapshot (pre-migration) · ${new Date().toISOString()}\n\n` +
objs.map((o) => sqlGenerator.generateObjectDdl(o)).join('\n');
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/format/diffPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export function describeIndex(i: IndexDiff): string {

export function describeFk(f: ForeignKeyDiff): string {
const side = f.target ?? f.source;
return side ? `(${side.columns.join(', ')}) → ${side.referencedTable}(${side.referencedColumns.join(', ')})` : '';
return side ? `(${side.columns.join(', ')}) → ${side.referencedTable}(${(side.referencedColumns ?? []).join(', ')})` : '';
}

export function describeTrigger(t: TriggerDiff): string {
Expand Down
43 changes: 43 additions & 0 deletions apps/cli/src/runtime/engine.normalize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import * as engine from './engine';

describe('loadScopedTables normalize', () => {
it('fills omitted referencedColumns via normalizeTableSchemas', async () => {
const getTables = vi.fn().mockResolvedValue([
{
name: 'parent',
objectType: 'TABLE',
columns: [
{ name: 'a', type: 'INT', nullable: false, primaryKey: true },
{ name: 'b', type: 'INT', nullable: false, primaryKey: true },
],
primaryKey: { name: 'pk', columns: ['a', 'b'] },
indices: [],
foreignKeys: [],
},
{
name: 'child',
objectType: 'TABLE',
columns: [
{ name: 'a', type: 'INT', nullable: true },
{ name: 'b', type: 'INT', nullable: true },
],
indices: [],
foreignKeys: [
{
name: 'fk_child',
columns: ['a', 'b'],
referencedTable: 'parent',
// omit referencedColumns — upgrade path
},
],
},
]);
vi.spyOn(engine.connectionModule, 'getProvider').mockReturnValue({ getTables } as never);

const tables = await engine.loadScopedTables('postgres', {} as never, 'public');
const child = tables.find((t) => t.name === 'child');
const fk = child?.foreignKeys[0];
expect(fk?.referencedColumns).toEqual(['a', 'b']);
});
});
4 changes: 2 additions & 2 deletions apps/cli/src/runtime/engine.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ConnectionModule, MigrationModule } from '@foxschema/core';
import { ConnectionModule, MigrationModule, normalizeTableSchemas } from '@foxschema/core';
import { CompareModule, SqlGeneratorModule, type ConnectionOptions, type DbObjectType, type TableSchema, type TableDiff } from '@foxschema/core';

// Shared engine singletons — the same modules the web/desktop apps use.
Expand All @@ -18,7 +18,7 @@ export async function loadScopedTables(
if (!provider.getTables) {
throw new Error(`The "${dialect}" provider can't list objects.`);
}
const tables = await provider.getTables(option, schema);
const tables = normalizeTableSchemas(await provider.getTables(option, schema));
return scope && scope.length ? tables.filter((t) => scope.includes(t.objectType)) : tables;
}

Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/tui/data/useMigrate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { MigrationEvent, MigrationStep, TableDiff } from '@foxschema/core';
import { normalizeTableSchemas, type MigrationEvent, type MigrationStep, type TableDiff } from '@foxschema/core';
import { connectionModule, migrationModule, sqlGenerator } from '../../runtime/engine';
import { getContext } from '../../runtime/store';
import { friendlyError } from '../../format/friendlyError';
Expand Down Expand Up @@ -95,7 +95,7 @@ export function useMigrate(
try {
const provider = connectionModule.getProvider(target.dialect);
if (provider.getTables) {
const objs = await provider.getTables(target.option, target.schema);
const objs = normalizeTableSchemas(await provider.getTables(target.option, target.schema));
snapshotDdl = `-- Target snapshot (pre-migration) · ${new Date().toISOString()}\n\n` + objs.map((o) => sqlGenerator.generateObjectDdl(o)).join('\n');
}
await migrationModule.execute(target.dialect, target.option, target.schema, steps, send, { continueOnError });
Expand Down
4 changes: 2 additions & 2 deletions apps/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"test:headed": "HEADLESS=false vitest run --config vitest.config.ts",
"test:smoke": "vitest run --config vitest.config.ts src/tests/smoke.test.ts",
"test:smoke:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/smoke.test.ts",
"test:sql-editor": "vitest run --config vitest.config.ts src/tests/sql-editor-smoke.test.ts src/tests/sql-editor-sqlite.test.ts",
"test:sql-editor:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/sql-editor-smoke.test.ts src/tests/sql-editor-sqlite.test.ts",
"test:sql-editor": "vitest run --config vitest.config.ts src/tests/sql-editor-smoke.test.ts src/tests/sql-editor-sqlite.test.ts src/tests/sql-editor-blueprint.test.ts",
"test:sql-editor:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/sql-editor-smoke.test.ts src/tests/sql-editor-sqlite.test.ts src/tests/sql-editor-blueprint.test.ts",
"test:errors": "vitest run --config vitest.config.ts src/tests/auto-error-catch.test.ts",
"test:errors:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/auto-error-catch.test.ts",
"test:postgres": "vitest run --config vitest.config.ts src/tests/dialects/postgres.test.ts",
Expand Down
6 changes: 5 additions & 1 deletion apps/e2e/scripts/run-all.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ console.log(bar + '\n');

// Always run SQL Editor suites first (self-seeding SQLite; no Docker required).
const ALWAYS = [
{ key: 'sql-editor', file: 'src/tests/sql-editor-smoke.test.ts src/tests/sql-editor-sqlite.test.ts', label: 'SQL Editor' },
{
key: 'sql-editor',
file: 'src/tests/sql-editor-smoke.test.ts src/tests/sql-editor-sqlite.test.ts src/tests/sql-editor-blueprint.test.ts',
label: 'SQL Editor',
},
];

for (const suite of ALWAYS) {
Expand Down
69 changes: 69 additions & 0 deletions apps/e2e/src/pages/SqlEditorPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,75 @@ export class SqlEditorPage {
return this.page.locator('[data-testid="sql-schema-explorer"]').isVisible();
}

/** Expand TABLES group and open the blueprint for a table by name. */
async openTableBlueprint(tableName: string): Promise<void> {
await this.dismissOverlays();
await this.closeBlueprint().catch(() => undefined);
const explorer = this.page.locator('[data-testid="sql-schema-explorer"]');
await explorer.waitFor({ state: 'visible', timeout: 15_000 });
// Expand TABLES group only when the table name is not already visible
// (a second click would collapse it).
const alreadyVisible = await explorer.getByText(tableName, { exact: true }).count();
if (alreadyVisible === 0) {
const group = explorer.locator('[data-testid="sql-schema-group-TABLE"]');
if (await group.count()) {
await group.locator('button').first().click().catch(() => undefined);
}
}
await this.page.waitForFunction(
(name) => {
const root = document.querySelector('[data-testid="sql-schema-explorer"]');
return !!root && new RegExp(name, 'i').test(root.textContent ?? '');
},
tableName,
{ timeout: 45_000 }
);
// Prefer the blueprint button nearest the table name label.
const nameLabel = explorer.getByText(tableName, { exact: true }).first();
await nameLabel.scrollIntoViewIfNeeded();
const row = nameLabel.locator('xpath=ancestor::div[contains(@class,"group")][1]');
await row.hover();
await row.locator('[data-testid="sql-open-blueprint"]').click({ force: true });
await waitFor(this.page, '[data-testid="table-blueprint-modal"]', 15_000);
}

async openNewTableBlueprint(): Promise<void> {
await this.dismissOverlays();
await clickWhen(this.page, '[data-testid="sql-new-table"]');
await waitFor(this.page, '[data-testid="table-blueprint-modal"]', 15_000);
}

async blueprintInsertSql(): Promise<void> {
await clickWhen(this.page, '[data-testid="blueprint-insert-sql"]');
}

async closeBlueprint(): Promise<void> {
const modal = this.page.locator('[data-testid="table-blueprint-modal"]');
if (!(await modal.isVisible().catch(() => false))) return;
const closeBtn = this.page.locator('[data-testid="blueprint-close"]');
if (await closeBtn.isVisible().catch(() => false)) {
await closeBtn.click();
} else {
// Click the backdrop (outer modal shell closes on click).
await modal.click({ position: { x: 4, y: 4 } });
}
await modal.waitFor({ state: 'detached', timeout: 8_000 });
}

async clickPageNext(): Promise<void> {
await clickWhen(this.page, '[data-testid="sql-page-next"]');
}

async clickPagePrev(): Promise<void> {
await clickWhen(this.page, '[data-testid="sql-page-prev"]');
}

async pageNextEnabled(): Promise<boolean> {
const btn = this.page.locator('[data-testid="sql-page-next"]');
if (!(await btn.isVisible().catch(() => false))) return false;
return !(await btn.isDisabled());
}

/** Wipe persisted editor tabs so tests start from a clean Query 1. */
async resetPersistedEditorState(): Promise<void> {
await this.page.evaluate(() => {
Expand Down
20 changes: 15 additions & 5 deletions apps/e2e/src/tests/dialects/shared-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,21 @@ import { ConnectionModal } from '../../pages/ConnectionModal.js';
import { MigrationPage } from '../../pages/MigrationPage.js';
import type { DbConfig } from '../../helpers/db-config.js';

export interface DialectFlowOptions {
/**
* Skip execute/history steps. Used for dialects whose adapter is SELECT-only
* in the SQL Editor / e2e path (e.g. SQLite) so compare still gets coverage.
*/
skipMigration?: boolean;
}

export function runDialectFlow(
dialectLabel: string,
getSource: () => DbConfig,
getTarget: () => DbConfig
getTarget: () => DbConfig,
options: DialectFlowOptions = {}
): void {
const skipMigration = !!options.skipMigration;
let driver: Page;
let app: AppPage;
let modal: ConnectionModal;
Expand Down Expand Up @@ -92,7 +102,7 @@ export function runDialectFlow(

// ── 4. Migrate ──────────────────────────────────────────────────────────

it('execute button is present', async () => {
it.skipIf(skipMigration)('execute button is present', async () => {
const diffItems = await app.getDiffCount();
if (diffItems === 0) {
console.log(`[${dialectLabel}] No diff objects — skipping execute step`);
Expand All @@ -111,7 +121,7 @@ export function runDialectFlow(
expect(await migration.isExecuteEnabled()).toBe(true);
});

it('executes migration (non-destructive)', async () => {
it.skipIf(skipMigration)('executes migration (non-destructive)', async () => {
const diffItems = await app.getDiffCount();
if (diffItems === 0) {
console.log(`[${dialectLabel}] No diff objects — skipping migration`);
Expand Down Expand Up @@ -143,15 +153,15 @@ export function runDialectFlow(
expect(result, `Migration did not complete successfully`).toBe('complete');
});

it('no SEVERE console errors after migration', async () => {
it.skipIf(skipMigration)('no SEVERE console errors after migration', async () => {
const errors = getErrors().filter((e) => e.level === 'SEVERE');
if (errors.length) await saveScreenshot(driver, `${dialectLabel}_post_migrate_error`);
expect(errors, errors.map((e) => e.message).join('\n')).toHaveLength(0);
});

// ── 5. History ──────────────────────────────────────────────────────────

it('migration history shows the run', async () => {
it.skipIf(skipMigration)('migration history shows the run', async () => {
await migration.openHistory();
expect(await migration.isHistoryVisible()).toBe(true);

Expand Down
6 changes: 5 additions & 1 deletion apps/e2e/src/tests/dialects/sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ import { runDialectFlow } from './shared-flow.js';

const DIALECT = 'sqlite';

// SQLite adapter is SELECT-only for execute paths used by migration deploy —
// cover connect + compare here; DDL/apply is exercised via SQL Editor blueprint
// tests against local sqlite3-seeded files.
describe.skipIf(!hasConfig(DIALECT))(`Compare flow: ${DIALECT}`, () => {
runDialectFlow(
DIALECT,
() => getSourceConfig(DIALECT)!,
() => getTargetConfig(DIALECT)!
() => getTargetConfig(DIALECT)!,
{ skipMigration: true }
);
});
Loading
Loading