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
39 changes: 33 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

# Fox Schema

## 0.3.X COMMING SOON, NEED TO DO MORE MANUALLY TEST *

**Compare schemas · generate migrations · run SQL — across 14 SQL dialects.**

Install once, then open the local web UI (`foxschema`) for **Compare**, the
Expand Down Expand Up @@ -47,6 +45,18 @@ npm install -g foxschema

Full matrix: **[docs/install.md](docs/install.md)**.

## Update

```bash
npm install -g foxschema@latest # npm
brew update && brew upgrade foxschema # Homebrew
docker pull 5nickels/foxschema:latest # Docker, then recreate the container
```

A running `foxschema` also tells you when a new version is out, links to
**What's new**, and on a local install offers **Update now**.
Release notes: [0.3.1](docs/releases/RELEASE_0.3.1.md) · [all versions](docs/releases/SUMMARY.md).

## Use the app

```bash
Expand All @@ -64,7 +74,23 @@ foxschema migrate --source a --target b
foxschema tui
```

## SQL Editor (0.2)
## What's in 0.3

| Area | What you get |
|------|----------------|
| **Compare & deploy** | Color-coded schema diff, cross-dialect readiness, an execute gate that reviews a missing foreign key or a narrowing type before it runs, streamed deploy progress |
| **Snapshots** | Capture a schema version, browse the timeline and graph, compare two versions, revert, or force-migrate a version to another database |
| **Migrations in Git** | Commit a migration to a repository, pull and push, track what was applied where |
| **SQL Editor** | Multi-destination run, notebook cells, FoxScript, Data Peek, Safe mode, one transaction where the engine can roll back (below) |
| **Data compare & migrate** | Compare 2,000 rows in key order between two databases, then apply up to 2,000 changes with a backup snapshot you can restore |
| **Utilities** | **Rename Column** (in place, or Large table mode with a batched copy, on every SQL engine), Index management, Backup & Restore commands, SQL Tracer, Server insights, Clone table |
| **Access** | Database accounts, a permission builder that writes GRANT / REVOKE, an inspector of who holds what, an access report |
| **Workspaces & sign-in** | Personal and team workspaces, invites, Google / Microsoft / GitHub sign-in, admin roles and a role → permission matrix |
| **Workflow** | Optional designer and engine for scheduled and triggered jobs (below) |

Every feature, where it lives and how it is tested: [docs/FEATURE_MAP.md](docs/FEATURE_MAP.md).

## SQL Editor

A full query workbench next to Schema Sync — not a thin prompt.

Expand All @@ -81,7 +107,7 @@ A full query workbench next to Schema Sync — not a thin prompt.
| **Schema explorer** | Insert names, autocomplete, edit table / view source |
| **Variables & secrets** | `${{name}}`, `@set`, session secrets, code-cell `vars` |
| **Safe mode** | Confirm writes; bookmarks & samples included |
| **Access control** | Optional `admin` / `editor` / `viewer` roles when multi-user login is on |
| **Access control** | Sign-in on every install; `admin` / `editor` / `viewer` roles and per-workspace roles |

Walkthrough: [User guide → SQL Editor](docs/user-guide.md#sql-editor).

Expand Down Expand Up @@ -133,8 +159,9 @@ Open **http://localhost:3210**. Guide: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
PostgreSQL · CockroachDB · YugabyteDB · MySQL · MariaDB · TiDB · SQL Server ·
Azure SQL · Oracle · IBM Db2 · SQLite · DuckDB · ClickHouse · Amazon Redshift

MongoDB and Redis appear in the connection list (settings only — no schema
compare). One product — Docker image includes Db2 on linux/amd64.
MongoDB and Redis run a SQL subset in the editor (single-table SELECT, INSERT,
UPDATE, DELETE; Redis keys read as tables) — no schema compare. One product —
Docker image includes Db2 on linux/amd64.

## Package notes (npm)

Expand Down
28 changes: 27 additions & 1 deletion apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,33 @@ npm install -g foxschema
foxschema # UI → http://localhost:3210
```

## SQL Editor (0.2)
## Update

```bash
npm install -g foxschema@latest # npm
brew update && brew upgrade foxschema # Homebrew
docker pull 5nickels/foxschema:latest # Docker, then recreate the container
```

A running `foxschema` tells you when a new version is out and offers **Update now**.
What's new: [0.3.1 release notes](https://github.com/tedious-code/foxschema/blob/main/docs/releases/RELEASE_0.3.1.md).

## What's in 0.3

| | |
|--|--|
| Compare & deploy | Schema diff, cross-dialect readiness, a review before a missing FK or a narrowing type runs |
| Snapshots | Capture schema versions, compare, revert, force-migrate to another database |
| Migrations in Git | Commit, pull and push migrations; track what was applied |
| SQL Editor | Multi-destination run, notebook cells, Data Peek, Safe mode, one transaction |
| Data compare & migrate | 2,000 rows in key order; apply up to 2,000 changes with a restorable backup |
| Utilities | **Rename Column** on every SQL engine (in place or a batched copy), indexes, backup commands, SQL Tracer |
| Access | Database accounts, GRANT / REVOKE builder, who-holds-what inspector |
| Workspaces & sign-in | Team workspaces, invites, Google / Microsoft / GitHub sign-in, roles |

All features: [FEATURE_MAP.md](https://github.com/tedious-code/foxschema/blob/main/docs/FEATURE_MAP.md).

## SQL Editor

Multi-destination queries, notebook-style cells, Data Peek, FoxScript, variables,
and Safe mode — next to Schema Sync in the same UI.
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@foxschema/cli",
"version": "0.2.396",
"version": "0.3.1",
"private": true,
"type": "module",
"description": "Fox Schema CLI — schema diff, migrations, and a rich SQL Editor (TypeScript / Node ESM)",
Expand Down
5 changes: 4 additions & 1 deletion apps/e2e/src/generated/test-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2997,6 +2997,8 @@ export const TestIds = {
columnChangeHint: 'column-change-hint',
/** section · What it touches */
columnChangeImpact: 'column-change-impact',
/** input */
columnChangeLargeTable: 'column-change-large-table',
/** p */
columnChangeLoadError: 'column-change-load-error',
/** input */
Expand Down Expand Up @@ -3029,7 +3031,7 @@ export const TestIds = {
columnChangeStep: (value: string | number) => `column-change-step-${value}`,
/** button */
columnChangeStepCopy: (value: string | number) => `column-change-step-${value}-copy`,
/** section · ( steps) */
/** section · ( ) */
columnChangeSteps: 'column-change-steps',
/** select */
columnChangeTable: 'column-change-table',
Expand Down Expand Up @@ -4184,6 +4186,7 @@ export type TestId =
| 'column-change-copy-all'
| 'column-change-hint'
| 'column-change-impact'
| 'column-change-large-table'
| 'column-change-load-error'
| 'column-change-new-name'
| 'column-change-new-type'
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@foxschema/web",
"private": true,
"version": "0.2.396",
"version": "0.3.1",
"type": "module",
"exports": {
"./package.json": "./package.json"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,19 @@ vi.mock('@/shared/api/sqlApi', () => ({
executeSql: (...args: unknown[]) => executeSql(...args),
}));

const fetchTableInsight = vi.hoisted(() => vi.fn());
vi.mock('@/shared/api/schemaApi', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
fetchTableInsight: (...args: unknown[]) => fetchTableInsight(...args),
}));

vi.mock('@/features/compare', () => {
const state = { connections: [{ id: 'pg', name: 'Shop', dialect: 'postgres', schema: 'shop', hasPassword: true }] };
const state = {
connections: [
{ id: 'pg', name: 'Shop', dialect: 'postgres', schema: 'shop', hasPassword: true },
{ id: 'ora', name: 'Ledger', dialect: 'oracle', schema: 'shop', hasPassword: true },
],
};
const useSyncStore = (sel: (s: typeof state) => unknown) => sel(state);
useSyncStore.getState = () => state;
return { useSyncStore };
Expand Down Expand Up @@ -45,8 +56,13 @@ const pick = (testId: string, value: string) => fireEvent.change(screen.getByTes
describe('ColumnChangePanel', () => {
beforeEach(() => {
executeSql.mockReset();
fetchTableInsight.mockReset();
fetchTableInsight.mockResolvedValue({ estimatedRows: 120, sizeBytes: 16384 });
useSqlEditorStore.setState({
schemaCache: { pg: { status: 'ready', tables: [orders, statusFn] } },
schemaCache: {
pg: { status: 'ready', tables: [orders, statusFn] },
ora: { status: 'ready', tables: [orders] },
},
ensureSchema: async () => undefined,
ensureConnectionSelected: () => undefined,
} as never);
Expand All @@ -59,6 +75,7 @@ describe('ColumnChangePanel', () => {
pick('column-change-column', 'status');
expect(screen.getByTestId('column-change-hint')).toBeTruthy();
pick('column-change-new-type', 'varchar(40)');
fireEvent.click(screen.getByTestId('column-change-large-table'));

expect(screen.getByTestId('column-change-impact').textContent).toContain('orders_status_idx');
expect(screen.getByTestId('column-change-impact').textContent).toContain('orders_status_label');
Expand Down Expand Up @@ -110,6 +127,7 @@ describe('ColumnChangePanel', () => {
pick('column-change-table', 'orders');
pick('column-change-column', 'status');
pick('column-change-new-type', 'varchar(40)');
fireEvent.click(screen.getByTestId('column-change-large-table'));
fireEvent.click(screen.getByTestId('column-change-run-add'));
await waitFor(() => expect(executeSql).toHaveBeenCalled());
expect(executeSql.mock.calls[0]![0]).toMatchObject({ connectionId: 'pg' });
Expand All @@ -133,6 +151,7 @@ describe('ColumnChangePanel', () => {
pick('column-change-table', 'orders');
pick('column-change-column', 'status');
pick('column-change-new-type', 'varchar(40)');
fireEvent.click(screen.getByTestId('column-change-large-table'));
expect(screen.getByTestId('column-change-step-3').textContent).toContain('DO $fox_copy$');
fireEvent.click(screen.getByTestId('column-change-run-copy'));
await waitFor(() => expect(screen.getByTestId('column-change-progress').textContent).toMatch(/10/));
Expand Down Expand Up @@ -194,6 +213,7 @@ describe('ColumnChangePanel', () => {
pick('column-change-table', 'orders');
pick('column-change-column', 'status');
pick('column-change-new-type', 'varchar(40)');
fireEvent.click(screen.getByTestId('column-change-large-table'));
fireEvent.click(screen.getByTestId('column-change-run-all'));
await waitFor(() => expect(screen.getByTestId('column-change-run-status').textContent).toMatch(/All parts finished/));
const batches = executeSql.mock.calls.map((c) => (c[1] as string[]).join('\n'));
Expand All @@ -207,4 +227,86 @@ describe('ColumnChangePanel', () => {
expect(addAt).toBeGreaterThanOrEqual(0);
expect(dropAt).toBeGreaterThan(addAt);
});

describe('a type change in place (the default)', () => {
const typeIn = (type: string) => {
render(<ColumnChangePanel lockedConnectionId="pg" />);
pick('column-change-table', 'orders');
pick('column-change-column', 'status');
pick('column-change-new-type', type);
};

it('is one ALTER, with no copy part and no batch size until large-table mode is on', () => {
typeIn('varchar(40)');
expect(screen.getByTestId('column-change-steps').textContent).toContain('Change in place (1 step)');
expect(screen.getByTestId('column-change-step-1').textContent).toContain(
'ALTER TABLE "shop"."orders" ALTER COLUMN "status" TYPE varchar(40);'
);
expect(screen.queryByTestId('column-change-part-copy')).toBeNull();
expect(screen.queryByTestId('column-change-batch')).toBeNull();
expect((screen.getByTestId('column-change-large-table') as HTMLInputElement).checked).toBe(false);

fireEvent.click(screen.getByTestId('column-change-large-table'));
expect(screen.getByTestId('column-change-part-copy')).toBeTruthy();
expect(screen.getByTestId('column-change-batch')).toBeTruthy();
});

it('offers large-table mode only for a type change', () => {
render(<ColumnChangePanel lockedConnectionId="pg" />);
pick('column-change-table', 'orders');
pick('column-change-column', 'status');
pick('column-change-new-name', 'state');
expect(screen.queryByTestId('column-change-large-table')).toBeNull();
});

it('shows no line-by-line references for a type change, only the one-line warnings', () => {
typeIn('varchar(40)');
expect(screen.queryByTestId('column-change-references')).toBeNull();
expect(screen.getByTestId('column-change-warnings').textContent).toContain(
'Functions that may use the column: orders_status_label'
);
});

it('warns on a large table, from the catalog estimate, and not in large-table mode', async () => {
fetchTableInsight.mockResolvedValue({ estimatedRows: 4_000_000, sizeBytes: 2 * 1024 ** 3 });
typeIn('varchar(40)');
await waitFor(() =>
expect(screen.getByTestId('column-change-warnings').textContent).toMatch(
/orders is large \(about 4\.0 million rows, 2\.0 GB\)/
)
);
expect(fetchTableInsight.mock.calls[0]).toEqual([{ connectionId: 'pg', password: undefined }, { table: 'orders', schema: 'shop' }]);
fireEvent.click(screen.getByTestId('column-change-large-table'));
expect(screen.getByTestId('column-change-warnings').textContent).not.toMatch(/is large/);
});

it('says nothing about size when the estimate cannot be read', async () => {
fetchTableInsight.mockRejectedValue(new Error('no stats'));
typeIn('varchar(40)');
await waitFor(() => expect(fetchTableInsight).toHaveBeenCalled());
expect(screen.getByTestId('column-change-warnings').textContent).not.toMatch(/is large/);
});

it('runs the ALTER against this connection and says the column changed', async () => {
useSqlEditorStore.setState({ safeMode: false } as never);
executeSql.mockResolvedValue({ results: [{ ok: true, columns: [], rows: [], rowCount: 0, truncated: false, durationMs: 1 }] });
typeIn('varchar(40)');
fireEvent.click(screen.getByTestId('column-change-run-all'));
await waitFor(() => expect(screen.getByTestId('column-change-run-status').textContent).toBe('Column changed.'));
expect(executeSql).toHaveBeenCalledTimes(1);
expect((executeSql.mock.calls[0]![1] as string[])[0]).toContain('ALTER COLUMN "status" TYPE varchar(40)');
});
});

it('changes the type in place on an engine without large-table mode, and does not offer it', () => {
render(<ColumnChangePanel lockedConnectionId="ora" />);
pick('column-change-table', 'orders');
pick('column-change-column', 'status');
pick('column-change-new-type', 'VARCHAR2(40)');
expect(screen.getByTestId('column-change-step-1').textContent).toContain(
'ALTER TABLE "SHOP"."orders" MODIFY ("status" VARCHAR2(40));'
);
expect(screen.queryByTestId('column-change-large-table')).toBeNull();
expect(screen.queryByTestId('column-change-batch')).toBeNull();
});
});
Loading
Loading