Skip to content

Commit 456fabb

Browse files
bchapuisclaude
andcommitted
Add foreign key references support to schema fields
Allow schema fields to reference the primary key of another schema via a new `references` attribute. The schema dialog shows a popover button to select the referenced schema, consistent with existing field attribute buttons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4c45a54 commit 456fabb

7 files changed

Lines changed: 103 additions & 34 deletions

File tree

apps/api/src/routes/schemas.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ const fieldSchema = z.object({
2727
type: z.enum(["string", "integer", "number", "boolean", "datetime", "json"]),
2828
required: z.boolean().optional(),
2929
primaryKey: z.boolean().optional(),
30+
label: z.string().optional(),
31+
defaultValue: z.string().optional(),
32+
unique: z.boolean().optional(),
33+
references: z.string().optional(),
3034
});
3135

3236
const uniqueFields = (fields: z.infer<typeof fieldSchema>[]) => {

apps/app/src/components/schema-dialog.tsx

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Field, FieldType, SchemaEntity } from "@dafthunk/types";
22
import Asterisk from "lucide-react/icons/asterisk";
33
import Hash from "lucide-react/icons/hash";
44
import KeyRound from "lucide-react/icons/key-round";
5+
import Link from "lucide-react/icons/link";
56
import PlusCircle from "lucide-react/icons/plus-circle";
67
import Trash2 from "lucide-react/icons/trash-2";
78
import { useEffect, useState } from "react";
@@ -16,6 +17,11 @@ import {
1617
} from "@/components/ui/dialog";
1718
import { Input } from "@/components/ui/input";
1819
import { Label } from "@/components/ui/label";
20+
import {
21+
Popover,
22+
PopoverContent,
23+
PopoverTrigger,
24+
} from "@/components/ui/popover";
1925
import {
2026
Select,
2127
SelectContent,
@@ -45,9 +51,10 @@ const FIELD_TYPES: FieldType[] = [
4551
interface FieldEditorProps {
4652
fields: Field[];
4753
onChange: (fields: Field[]) => void;
54+
schemas?: SchemaEntity[];
4855
}
4956

50-
function FieldEditor({ fields, onChange }: FieldEditorProps) {
57+
function FieldEditor({ fields, onChange, schemas }: FieldEditorProps) {
5158
const addField = () => {
5259
onChange([...fields, { name: "", type: "string" }]);
5360
};
@@ -91,9 +98,7 @@ function FieldEditor({ fields, onChange }: FieldEditorProps) {
9198
<Input
9299
placeholder="Name"
93100
value={field.name}
94-
onChange={(e) =>
95-
updateField(index, { name: e.target.value })
96-
}
101+
onChange={(e) => updateField(index, { name: e.target.value })}
97102
className={cn(
98103
"flex-1 min-w-0",
99104
isDuplicate && "border-destructive"
@@ -151,8 +156,7 @@ function FieldEditor({ fields, onChange }: FieldEditorProps) {
151156
onChange(
152157
fields.map((f, i) => ({
153158
...f,
154-
primaryKey:
155-
i === index ? true : undefined,
159+
primaryKey: i === index ? true : undefined,
156160
}))
157161
);
158162
}
@@ -201,9 +205,7 @@ function FieldEditor({ fields, onChange }: FieldEditorProps) {
201205
</Button>
202206
</TooltipTrigger>
203207
<TooltipContent>
204-
{field.unique
205-
? "Remove unique constraint"
206-
: "Set as unique"}
208+
{field.unique ? "Remove unique constraint" : "Set as unique"}
207209
</TooltipContent>
208210
</Tooltip>
209211
</TooltipProvider>
@@ -232,12 +234,73 @@ function FieldEditor({ fields, onChange }: FieldEditorProps) {
232234
</Button>
233235
</TooltipTrigger>
234236
<TooltipContent>
235-
{field.required
236-
? "Set as optional"
237-
: "Set as required"}
237+
{field.required ? "Set as optional" : "Set as required"}
238238
</TooltipContent>
239239
</Tooltip>
240240
</TooltipProvider>
241+
{schemas && schemas.length > 0 && (
242+
<Popover>
243+
<TooltipProvider>
244+
<Tooltip>
245+
<TooltipTrigger asChild>
246+
<PopoverTrigger asChild>
247+
<Button
248+
type="button"
249+
variant={field.references ? "secondary" : "ghost"}
250+
size="icon"
251+
className="h-8 w-8 shrink-0"
252+
>
253+
<Link
254+
className={cn(
255+
"h-4 w-4",
256+
field.references
257+
? "text-foreground"
258+
: "text-muted-foreground"
259+
)}
260+
/>
261+
</Button>
262+
</PopoverTrigger>
263+
</TooltipTrigger>
264+
<TooltipContent>
265+
{field.references
266+
? `References ${field.references}`
267+
: "Set foreign key reference"}
268+
</TooltipContent>
269+
</Tooltip>
270+
</TooltipProvider>
271+
<PopoverContent className="w-40 p-1" align="start">
272+
<button
273+
type="button"
274+
className={cn(
275+
"w-full rounded-sm px-2 py-1.5 text-sm text-left hover:bg-accent",
276+
!field.references && "font-medium"
277+
)}
278+
onClick={() =>
279+
updateField(index, { references: undefined })
280+
}
281+
>
282+
None
283+
</button>
284+
{schemas
285+
.filter((s) => s.fields.some((f) => f.primaryKey))
286+
.map((s) => (
287+
<button
288+
key={s.id}
289+
type="button"
290+
className={cn(
291+
"w-full rounded-sm px-2 py-1.5 text-sm text-left hover:bg-accent",
292+
field.references === s.name && "font-medium"
293+
)}
294+
onClick={() =>
295+
updateField(index, { references: s.name })
296+
}
297+
>
298+
{s.name}
299+
</button>
300+
))}
301+
</PopoverContent>
302+
</Popover>
303+
)}
241304
<Button
242305
type="button"
243306
variant="ghost"
@@ -258,6 +321,7 @@ export interface SchemaDialogProps {
258321
open: boolean;
259322
onOpenChange: (open: boolean) => void;
260323
schema?: SchemaEntity | null;
324+
schemas?: SchemaEntity[];
261325
onSubmit: (data: {
262326
name: string;
263327
description: string;
@@ -271,6 +335,7 @@ export function SchemaDialog({
271335
open,
272336
onOpenChange,
273337
schema,
338+
schemas,
274339
onSubmit,
275340
title,
276341
submitLabel,
@@ -336,7 +401,7 @@ export function SchemaDialog({
336401
rows={2}
337402
/>
338403
</div>
339-
<FieldEditor fields={fields} onChange={setFields} />
404+
<FieldEditor fields={fields} onChange={setFields} schemas={schemas} />
340405
<DialogFooter>
341406
<Button
342407
variant="outline"

apps/app/src/pages/database-explorer-page.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,7 @@ import { useParams } from "react-router";
2424
import { InsetError } from "@/components/inset-error";
2525
import { InsetLoading } from "@/components/inset-loading";
2626
import { usePageBreadcrumbs } from "@/hooks/use-page";
27-
import {
28-
useDatabase,
29-
useDatabaseSchema,
30-
} from "@/services/database-service";
27+
import { useDatabase, useDatabaseSchema } from "@/services/database-service";
3128
import { cn } from "@/utils/utils";
3229

3330
// --- Schema Table Node ---
@@ -59,8 +56,16 @@ function SchemaTableNode({ data }: NodeProps<Node<SchemaTableNodeData>>) {
5956
className="!w-[9px] !h-[9px] !bg-muted !border-[1.5px] !border-border"
6057
isConnectable={false}
6158
/>
62-
<span className={col.primaryKey ? "font-semibold" : "text-foreground/80"}>{col.name}</span>
63-
<span className="text-muted-foreground/60 font-light uppercase">{col.type || "TEXT"}</span>
59+
<span
60+
className={
61+
col.primaryKey ? "font-semibold" : "text-foreground/80"
62+
}
63+
>
64+
{col.name}
65+
</span>
66+
<span className="text-muted-foreground/60 font-light uppercase">
67+
{col.type || "TEXT"}
68+
</span>
6469
<Handle
6570
type="source"
6671
position={Position.Right}
@@ -177,7 +182,7 @@ function SchemaFlowCanvas({ tables }: SchemaFlowCanvasProps) {
177182
nodesConnectable={false}
178183
elementsSelectable={false}
179184
fitView
180-
fitViewOptions={{ padding: 0}}
185+
fitViewOptions={{ padding: 0 }}
181186
minZoom={0.1}
182187
maxZoom={4}
183188
>
@@ -235,9 +240,7 @@ export function DatabaseExplorerPage() {
235240
return (
236241
<div className="flex flex-col h-full">
237242
<div className="border-b bg-background px-6 py-4 flex items-center">
238-
<h1 className="text-xl font-semibold">
239-
{database.name} - Explorer
240-
</h1>
243+
<h1 className="text-xl font-semibold">{database.name} - Explorer</h1>
241244
</div>
242245
<div className="flex-1 min-h-0">
243246
<ReactFlowProvider>

apps/app/src/pages/schemas-page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export function SchemasPage() {
227227
<SchemaDialog
228228
open={isCreateDialogOpen}
229229
onOpenChange={setIsCreateDialogOpen}
230+
schemas={schemas}
230231
onSubmit={handleCreate}
231232
title="Create New Schema"
232233
submitLabel="Create Schema"
@@ -237,6 +238,7 @@ export function SchemasPage() {
237238
if (!open) setEditSchema(null);
238239
}}
239240
schema={editSchema}
241+
schemas={schemas}
240242
onSubmit={handleEdit}
241243
title="Edit Schema"
242244
submitLabel="Save Changes"

packages/runtime/src/nodes/database/database-describe-table-node.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,7 @@ export class DatabaseDescribeTableNode extends ExecutableNode {
8989
// Collect unique columns from single-column unique indexes
9090
const uniqueColumns = new Set<string>();
9191
try {
92-
const indexList = await connection.query(
93-
`PRAGMA index_list(${table})`
94-
);
92+
const indexList = await connection.query(`PRAGMA index_list(${table})`);
9593
if (indexList.results) {
9694
for (const idx of indexList.results as unknown as Array<{
9795
name: string;
@@ -101,10 +99,7 @@ export class DatabaseDescribeTableNode extends ExecutableNode {
10199
const indexInfo = await connection.query(
102100
`PRAGMA index_info(${idx.name})`
103101
);
104-
if (
105-
indexInfo.results &&
106-
indexInfo.results.length === 1
107-
) {
102+
if (indexInfo.results && indexInfo.results.length === 1) {
108103
const col = indexInfo.results[0] as unknown as {
109104
name: string;
110105
};
@@ -120,9 +115,7 @@ export class DatabaseDescribeTableNode extends ExecutableNode {
120115
name: col.name,
121116
type: mapSqliteToType(col.type || "TEXT"),
122117
...(col.pk ? { primaryKey: true } : {}),
123-
...(!col.pk && uniqueColumns.has(col.name)
124-
? { unique: true }
125-
: {}),
118+
...(!col.pk && uniqueColumns.has(col.name) ? { unique: true } : {}),
126119
}));
127120

128121
const schema: Schema = {

packages/runtime/src/nodes/database/database-put-row-node.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export class DatabasePutRowNode extends ExecutableNode {
1010
id: "database-put-row",
1111
name: "Database Put Row",
1212
type: "database-put-row",
13-
description: "Inserts a row, or replaces it if a matching primary key exists.",
13+
description:
14+
"Inserts a row, or replaces it if a matching primary key exists.",
1415
tags: ["Database", "Put", "Row"],
1516
icon: "database",
1617
documentation:

packages/types/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface Field {
2929
label?: string;
3030
defaultValue?: string;
3131
unique?: boolean;
32+
references?: string;
3233
}
3334

3435
/**

0 commit comments

Comments
 (0)