2025-08-03 21:30:02 +00:00
|
|
|
import { createMemo, createSignal, Index, Match, Show, Switch } from "solid-js";
|
2025-03-10 17:22:08 +00:00
|
|
|
import type { Accessor } from "solid-js";
|
2024-10-30 22:38:29 +00:00
|
|
|
import { createForm } from "@tanstack/solid-form";
|
2025-05-11 09:11:22 +00:00
|
|
|
import { useQueryClient } from "@tanstack/solid-query";
|
2024-10-30 22:38:29 +00:00
|
|
|
|
|
|
|
|
import { showToast } from "@/components/ui/toast";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
DialogTrigger,
|
|
|
|
|
} from "@/components/ui/dialog";
|
|
|
|
|
import { SheetHeader, SheetTitle, SheetFooter } from "@/components/ui/sheet";
|
|
|
|
|
|
|
|
|
|
import { createTable, alterTable } from "@/lib/table";
|
|
|
|
|
import { randomName } from "@/lib/name";
|
|
|
|
|
import {
|
|
|
|
|
buildBoolFormField,
|
|
|
|
|
buildTextFormField,
|
|
|
|
|
} from "@/components/FormFields";
|
|
|
|
|
import { SheetContainer } from "@/components/SafeSheet";
|
2025-03-10 21:10:58 +00:00
|
|
|
import {
|
|
|
|
|
PrimaryKeyColumnSubForm,
|
|
|
|
|
ColumnSubForm,
|
|
|
|
|
primaryKeyPresets,
|
|
|
|
|
} from "@/components/tables/CreateAlterColumnForm";
|
2025-01-31 11:43:49 +00:00
|
|
|
import { invalidateConfig } from "@/lib/config";
|
2024-10-30 22:38:29 +00:00
|
|
|
|
2025-03-09 12:31:09 +00:00
|
|
|
import type { Column } from "@bindings/Column";
|
|
|
|
|
import type { Table } from "@bindings/Table";
|
2025-08-03 14:45:01 +00:00
|
|
|
import type { AlterTableOperation } from "@bindings/AlterTableOperation";
|
|
|
|
|
import type { QualifiedName } from "@bindings/QualifiedName";
|
2025-03-09 12:31:09 +00:00
|
|
|
|
2025-08-03 21:30:02 +00:00
|
|
|
function newDefaultColumn(index: number, existingNames?: string[]): Column {
|
|
|
|
|
let name = `new_${index}`;
|
|
|
|
|
if (existingNames !== undefined) {
|
|
|
|
|
for (let i = 0; i < 1000; ++i) {
|
|
|
|
|
if (existingNames.find((n) => n === name) === undefined) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
name = `new_${index + i}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-03-10 17:22:08 +00:00
|
|
|
return {
|
2025-08-03 21:30:02 +00:00
|
|
|
name,
|
2025-03-10 17:22:08 +00:00
|
|
|
data_type: "Text",
|
|
|
|
|
options: [{ Default: "''" }],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-03 21:30:02 +00:00
|
|
|
function columnsEqual(a: Column, b: Column): boolean {
|
|
|
|
|
return (
|
|
|
|
|
a.name === b.name &&
|
|
|
|
|
a.data_type === b.data_type &&
|
|
|
|
|
JSON.stringify(a.options) === JSON.stringify(b.options)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-30 22:38:29 +00:00
|
|
|
export function CreateAlterTableForm(props: {
|
|
|
|
|
close: () => void;
|
|
|
|
|
markDirty: () => void;
|
|
|
|
|
schemaRefetch: () => Promise<void>;
|
|
|
|
|
allTables: Table[];
|
2025-05-22 21:04:52 +00:00
|
|
|
setSelected: (tableName: QualifiedName) => void;
|
2024-10-30 22:38:29 +00:00
|
|
|
schema?: Table;
|
|
|
|
|
}) {
|
2025-05-11 09:11:22 +00:00
|
|
|
const queryClient = useQueryClient();
|
2024-10-30 22:38:29 +00:00
|
|
|
const [sql, setSql] = createSignal<string | undefined>();
|
|
|
|
|
|
2025-08-03 14:45:01 +00:00
|
|
|
const copyOriginal = (): Table | undefined =>
|
|
|
|
|
props.schema ? JSON.parse(JSON.stringify(props.schema)) : undefined;
|
|
|
|
|
|
2025-08-03 20:37:06 +00:00
|
|
|
const original = createMemo<Table | undefined>(() => copyOriginal());
|
|
|
|
|
const isCreateTable = () => original() === undefined;
|
|
|
|
|
|
2025-08-03 21:30:02 +00:00
|
|
|
// Columns are treated as append only. Instead of removing it and inducing animation junk and other stuff when
|
|
|
|
|
// shifting offset, we simply don't render columns that were marked as deleted.
|
|
|
|
|
const [deletedColumns, setDeletedColumn] = createSignal<number[]>([]);
|
|
|
|
|
const isDeleted = (i: number): boolean =>
|
|
|
|
|
deletedColumns().find((idx) => idx === i) !== undefined;
|
2024-10-30 22:38:29 +00:00
|
|
|
|
|
|
|
|
const onSubmit = async (value: Table, dryRun: boolean) => {
|
2025-03-01 13:50:08 +00:00
|
|
|
/* eslint-disable solid/reactivity */
|
2024-10-30 22:38:29 +00:00
|
|
|
console.debug("Table schema:", value);
|
|
|
|
|
|
|
|
|
|
try {
|
2025-03-01 10:12:56 +00:00
|
|
|
const o = original();
|
2025-07-18 19:30:07 +00:00
|
|
|
if (o !== undefined) {
|
2025-08-02 19:39:04 +00:00
|
|
|
// Alter table
|
2025-08-03 21:30:02 +00:00
|
|
|
|
|
|
|
|
// Build operations. Remember columns are append-only.
|
2025-08-03 14:45:01 +00:00
|
|
|
const operations: AlterTableOperation[] = [];
|
2025-08-03 21:30:02 +00:00
|
|
|
value.columns.forEach((column, i) => {
|
|
|
|
|
if (i < o.columns.length) {
|
|
|
|
|
// Pre-existing column.
|
|
|
|
|
const originalName = o.columns[i].name;
|
|
|
|
|
if (isDeleted(i)) {
|
|
|
|
|
operations.push({ DropColumn: { name: originalName } });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-08-03 14:45:01 +00:00
|
|
|
|
2025-08-03 21:30:02 +00:00
|
|
|
if (!columnsEqual(o.columns[i], column)) {
|
|
|
|
|
operations.push({
|
|
|
|
|
AlterColumn: {
|
|
|
|
|
name: originalName,
|
|
|
|
|
column,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-08-03 14:45:01 +00:00
|
|
|
} else {
|
2025-08-03 21:30:02 +00:00
|
|
|
// Newly added columns.
|
|
|
|
|
if (!isDeleted(i)) {
|
|
|
|
|
operations.push({ AddColumn: { column } });
|
|
|
|
|
}
|
2025-08-03 14:45:01 +00:00
|
|
|
}
|
2025-08-03 21:30:02 +00:00
|
|
|
});
|
2025-08-03 14:45:01 +00:00
|
|
|
|
2024-10-30 22:38:29 +00:00
|
|
|
const response = await alterTable({
|
2025-03-01 10:12:56 +00:00
|
|
|
source_schema: o,
|
2025-08-03 14:45:01 +00:00
|
|
|
operations,
|
2025-07-18 10:12:45 +00:00
|
|
|
dry_run: dryRun,
|
2024-10-30 22:38:29 +00:00
|
|
|
});
|
2025-08-02 19:39:04 +00:00
|
|
|
console.debug(`AlterTableResponse [dry: ${dryRun}]:`, response);
|
2025-07-18 10:12:45 +00:00
|
|
|
|
|
|
|
|
if (dryRun) {
|
|
|
|
|
setSql(response.sql);
|
|
|
|
|
}
|
2024-10-30 22:38:29 +00:00
|
|
|
} else {
|
2025-08-02 19:39:04 +00:00
|
|
|
// Create table
|
2025-08-03 21:30:02 +00:00
|
|
|
value.columns = value.columns.filter((_, i) => !isDeleted(i));
|
2024-10-30 22:38:29 +00:00
|
|
|
const response = await createTable({ schema: value, dry_run: dryRun });
|
|
|
|
|
console.debug(`CreateTableResponse [dry: ${dryRun}]:`, response);
|
|
|
|
|
|
|
|
|
|
if (dryRun) {
|
|
|
|
|
setSql(response.sql);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!dryRun) {
|
2025-07-18 10:12:45 +00:00
|
|
|
// Trigger config reload
|
2025-05-11 09:11:22 +00:00
|
|
|
invalidateConfig(queryClient);
|
2025-07-18 10:12:45 +00:00
|
|
|
|
|
|
|
|
// Reload schemas.
|
2024-10-30 22:38:29 +00:00
|
|
|
props.schemaRefetch().then(() => {
|
|
|
|
|
props.setSelected(value.name);
|
|
|
|
|
});
|
2025-07-18 10:12:45 +00:00
|
|
|
|
|
|
|
|
// Close dialog/sheet.
|
2024-10-30 22:38:29 +00:00
|
|
|
props.close();
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
showToast({
|
|
|
|
|
title: "Uncaught Error",
|
|
|
|
|
description: `${err}`,
|
|
|
|
|
variant: "error",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-03-03 13:41:01 +00:00
|
|
|
const form = createForm(() => ({
|
2025-08-02 19:39:04 +00:00
|
|
|
onSubmit: async ({ value }) => await onSubmit(value, /*dryRun=*/ false),
|
2025-03-03 13:41:01 +00:00
|
|
|
defaultValues:
|
2025-08-03 14:45:01 +00:00
|
|
|
copyOriginal() ??
|
2025-03-03 13:41:01 +00:00
|
|
|
({
|
2025-05-22 21:04:52 +00:00
|
|
|
name: {
|
|
|
|
|
name: randomName(),
|
|
|
|
|
database_schema: null,
|
|
|
|
|
},
|
2025-03-03 13:41:01 +00:00
|
|
|
strict: true,
|
|
|
|
|
indexes: [],
|
|
|
|
|
columns: [
|
|
|
|
|
{
|
|
|
|
|
name: "id",
|
2025-03-10 21:10:58 +00:00
|
|
|
...primaryKeyPresets[0][1]("id"),
|
2025-03-03 13:41:01 +00:00
|
|
|
},
|
|
|
|
|
newDefaultColumn(1),
|
|
|
|
|
] satisfies Column[],
|
|
|
|
|
// Table constraints: https://www.sqlite.org/syntax/table-constraint.html
|
|
|
|
|
unique: [],
|
|
|
|
|
foreign_keys: [],
|
2025-03-26 15:17:32 +00:00
|
|
|
checks: [],
|
2025-03-03 13:41:01 +00:00
|
|
|
virtual_table: false,
|
|
|
|
|
temporary: false,
|
|
|
|
|
} as Table),
|
2024-10-30 22:38:29 +00:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
form.useStore((state) => {
|
|
|
|
|
if (state.isDirty && !state.isSubmitted) {
|
|
|
|
|
props.markDirty();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<SheetContainer>
|
|
|
|
|
<SheetHeader>
|
2025-07-18 19:30:07 +00:00
|
|
|
<SheetTitle>
|
|
|
|
|
{isCreateTable() ? "Add New Table" : "Alter Table"}
|
|
|
|
|
</SheetTitle>
|
2024-10-30 22:38:29 +00:00
|
|
|
</SheetHeader>
|
|
|
|
|
|
|
|
|
|
<form
|
2025-06-11 19:40:16 +00:00
|
|
|
method="dialog"
|
|
|
|
|
onSubmit={(e: SubmitEvent) => {
|
2024-10-30 22:38:29 +00:00
|
|
|
e.preventDefault();
|
|
|
|
|
form.handleSubmit();
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-03-01 10:12:56 +00:00
|
|
|
<div class="mt-4 flex flex-col items-start gap-4 pr-4">
|
2024-10-30 22:38:29 +00:00
|
|
|
<form.Field
|
2025-05-22 21:04:52 +00:00
|
|
|
name="name.name"
|
2024-10-30 22:38:29 +00:00
|
|
|
validators={{
|
|
|
|
|
onChange: ({ value }: { value: string | undefined }) => {
|
|
|
|
|
return value ? undefined : "Table name missing";
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-03-10 17:22:08 +00:00
|
|
|
{buildTextFormField({
|
|
|
|
|
label: () => <TextLabel text="Table name" />,
|
|
|
|
|
})}
|
2024-10-30 22:38:29 +00:00
|
|
|
</form.Field>
|
|
|
|
|
|
2025-08-02 20:10:52 +00:00
|
|
|
<Show when={isCreateTable()}>
|
|
|
|
|
<form.Field
|
|
|
|
|
name="strict"
|
|
|
|
|
children={buildBoolFormField({
|
|
|
|
|
label: () => "STRICT (type-safe)",
|
|
|
|
|
})}
|
|
|
|
|
/>
|
|
|
|
|
</Show>
|
2024-10-30 22:38:29 +00:00
|
|
|
|
|
|
|
|
{/* columns */}
|
|
|
|
|
<h2>Columns</h2>
|
|
|
|
|
|
|
|
|
|
<form.Field name="columns">
|
2025-08-03 20:37:06 +00:00
|
|
|
{(field) => (
|
|
|
|
|
<div class="w-full">
|
|
|
|
|
<div class="flex flex-col gap-2">
|
|
|
|
|
<Index each={field().state.value}>
|
2025-08-03 21:30:02 +00:00
|
|
|
{(c: Accessor<Column>, i: number) => (
|
|
|
|
|
<Show when={!isDeleted(i)}>
|
|
|
|
|
<Switch>
|
|
|
|
|
<Match when={i === 0}>
|
|
|
|
|
<PrimaryKeyColumnSubForm
|
|
|
|
|
form={form}
|
|
|
|
|
colIndex={i}
|
|
|
|
|
column={c()}
|
|
|
|
|
allTables={props.allTables}
|
|
|
|
|
disabled={!isCreateTable()}
|
|
|
|
|
/>
|
|
|
|
|
</Match>
|
2025-08-03 14:45:01 +00:00
|
|
|
|
2025-08-03 21:30:02 +00:00
|
|
|
<Match when={i !== 0}>
|
|
|
|
|
<ColumnSubForm
|
|
|
|
|
form={form}
|
|
|
|
|
colIndex={i}
|
|
|
|
|
column={c()}
|
|
|
|
|
allTables={props.allTables}
|
|
|
|
|
disabled={false}
|
|
|
|
|
onDelete={() =>
|
|
|
|
|
setDeletedColumn([i, ...deletedColumns()])
|
2025-08-03 20:37:06 +00:00
|
|
|
}
|
2025-08-03 21:30:02 +00:00
|
|
|
/>
|
|
|
|
|
</Match>
|
|
|
|
|
</Switch>
|
|
|
|
|
</Show>
|
|
|
|
|
)}
|
2025-08-03 20:37:06 +00:00
|
|
|
</Index>
|
2025-08-03 14:45:01 +00:00
|
|
|
</div>
|
2025-08-03 20:37:06 +00:00
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
class="m-2"
|
|
|
|
|
onClick={() => {
|
2025-08-03 21:30:02 +00:00
|
|
|
const columns = field().state.value;
|
|
|
|
|
field().pushValue(
|
|
|
|
|
newDefaultColumn(
|
|
|
|
|
columns.length,
|
|
|
|
|
columns.map((c) => c.name),
|
|
|
|
|
),
|
|
|
|
|
);
|
2025-08-03 20:37:06 +00:00
|
|
|
}}
|
|
|
|
|
variant="default"
|
|
|
|
|
>
|
|
|
|
|
Add Column
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2024-10-30 22:38:29 +00:00
|
|
|
</form.Field>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<SheetFooter>
|
|
|
|
|
<form.Subscribe
|
|
|
|
|
selector={(state) => ({
|
|
|
|
|
canSubmit: state.canSubmit,
|
|
|
|
|
isSubmitting: state.isSubmitting,
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
{(state) => {
|
|
|
|
|
return (
|
2025-03-01 10:12:56 +00:00
|
|
|
<div class="flex items-center gap-4">
|
2025-07-18 19:30:07 +00:00
|
|
|
<Dialog
|
|
|
|
|
open={sql() !== undefined}
|
|
|
|
|
onOpenChange={(open: boolean) => {
|
|
|
|
|
if (!open) {
|
|
|
|
|
setSql(undefined);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DialogTrigger>
|
|
|
|
|
<Button
|
|
|
|
|
class="w-[92px]"
|
|
|
|
|
disabled={!state().canSubmit}
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => {
|
2025-08-02 19:39:04 +00:00
|
|
|
onSubmit(form.state.values, /*dryRun=*/ true).catch(
|
2025-07-18 19:30:07 +00:00
|
|
|
console.error,
|
|
|
|
|
);
|
|
|
|
|
}}
|
|
|
|
|
{...props}
|
|
|
|
|
>
|
|
|
|
|
{state().isSubmitting ? "..." : "Dry Run"}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogTrigger>
|
2024-10-30 22:38:29 +00:00
|
|
|
|
2025-07-18 19:30:07 +00:00
|
|
|
<DialogContent class="min-w-[80dvw]">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>SQL</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
2024-10-30 22:38:29 +00:00
|
|
|
|
2025-07-18 19:30:07 +00:00
|
|
|
<div class="overflow-auto">
|
|
|
|
|
<pre>{sql() === "" ? "<EMPTY>" : sql()}</pre>
|
|
|
|
|
</div>
|
2024-10-30 22:38:29 +00:00
|
|
|
|
2025-07-18 19:30:07 +00:00
|
|
|
<DialogFooter />
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2024-10-30 22:38:29 +00:00
|
|
|
|
2025-03-01 10:12:56 +00:00
|
|
|
<div class="mr-4 flex w-full justify-end">
|
2024-10-30 22:38:29 +00:00
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={!state().canSubmit}
|
|
|
|
|
variant="default"
|
|
|
|
|
>
|
|
|
|
|
{state().isSubmitting ? "..." : "Submit"}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}}
|
|
|
|
|
</form.Subscribe>
|
|
|
|
|
</SheetFooter>
|
|
|
|
|
</form>
|
|
|
|
|
</SheetContainer>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-10 17:22:08 +00:00
|
|
|
function TextLabel(props: { text: string }) {
|
|
|
|
|
return <div class="w-[100px]">{props.text}</div>;
|
2024-10-30 22:38:29 +00:00
|
|
|
}
|