Skip to content

Commit 043a073

Browse files
bchapuisclaude
andcommitted
Validate schema and field names as identifiers
Restrict names to letters, digits, and underscores (must start with a letter or underscore) so they work safely as SQL columns and JSON keys. Adds shared IDENTIFIER_PATTERN constant, backend Zod validation, and frontend inline error messages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f4adb20 commit 043a073

3 files changed

Lines changed: 67 additions & 24 deletions

File tree

apps/api/src/routes/schemas.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import type {
2-
CreateSchemaResponse,
3-
DeleteSchemaResponse,
4-
GetSchemaResponse,
5-
ListSchemasResponse,
6-
SchemaEntity,
7-
UpdateSchemaResponse,
1+
import {
2+
IDENTIFIER_PATTERN,
3+
type CreateSchemaResponse,
4+
type DeleteSchemaResponse,
5+
type GetSchemaResponse,
6+
type ListSchemasResponse,
7+
type SchemaEntity,
8+
type UpdateSchemaResponse,
89
} from "@dafthunk/types";
910
import { zValidator } from "@hono/zod-validator";
1011
import { Hono } from "hono";
@@ -22,8 +23,11 @@ import {
2223
updateSchemaRecord,
2324
} from "../db";
2425

26+
const identifierMessage =
27+
"Must start with a letter or underscore, and contain only letters, digits, or underscores";
28+
2529
const fieldSchema = z.object({
26-
name: z.string().min(1),
30+
name: z.string().min(1).regex(IDENTIFIER_PATTERN, identifierMessage),
2731
type: z.enum(["string", "integer", "number", "boolean", "datetime", "json"]),
2832
required: z.boolean().optional(),
2933
primaryKey: z.boolean().optional(),
@@ -88,7 +92,10 @@ schemaRoutes.post(
8892
"json",
8993
z
9094
.object({
91-
name: z.string().min(1, "Schema name is required"),
95+
name: z
96+
.string()
97+
.min(1, "Schema name is required")
98+
.regex(IDENTIFIER_PATTERN, identifierMessage),
9299
description: z.string().optional(),
93100
fields: z.array(fieldSchema).min(1, "At least one field is required"),
94101
})
@@ -152,7 +159,11 @@ schemaRoutes.put(
152159
"json",
153160
z
154161
.object({
155-
name: z.string().min(1).optional(),
162+
name: z
163+
.string()
164+
.min(1)
165+
.regex(IDENTIFIER_PATTERN, identifierMessage)
166+
.optional(),
156167
description: z.string().optional(),
157168
fields: z.array(fieldSchema).min(1).optional(),
158169
})

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

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Field, FieldType, SchemaEntity } from "@dafthunk/types";
1+
import { IDENTIFIER_PATTERN, type Field, type FieldType, type 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";
@@ -90,20 +90,28 @@ function FieldEditor({ fields, onChange, schemas }: FieldEditorProps) {
9090
</p>
9191
)}
9292
{fields.map((field, index) => {
93+
const trimmed = field.name.trim();
9394
const isDuplicate =
94-
field.name.trim() !== "" &&
95-
(nameCounts.get(field.name.trim()) ?? 0) > 1;
95+
trimmed !== "" && (nameCounts.get(trimmed) ?? 0) > 1;
96+
const isInvalidName =
97+
trimmed !== "" && !IDENTIFIER_PATTERN.test(trimmed);
9698
return (
9799
<div key={index} className="flex items-center gap-2">
98-
<Input
99-
placeholder="Name"
100-
value={field.name}
101-
onChange={(e) => updateField(index, { name: e.target.value })}
102-
className={cn(
103-
"flex-1 min-w-0",
104-
isDuplicate && "border-destructive"
100+
<div className="flex-1 min-w-0">
101+
<Input
102+
placeholder="Name"
103+
value={field.name}
104+
onChange={(e) => updateField(index, { name: e.target.value })}
105+
className={cn(
106+
(isDuplicate || isInvalidName) && "border-destructive"
107+
)}
108+
/>
109+
{isInvalidName && (
110+
<p className="text-xs text-destructive mt-1">
111+
Letters, digits, and underscores only
112+
</p>
105113
)}
106-
/>
114+
</div>
107115
<Input
108116
placeholder="Label"
109117
value={field.label ?? ""}
@@ -366,11 +374,16 @@ export function SchemaDialog({
366374

367375
const fieldNames = fields.map((f) => f.name.trim());
368376
const hasDuplicateNames = new Set(fieldNames).size !== fieldNames.length;
377+
const isNameValid =
378+
name.trim().length > 0 && IDENTIFIER_PATTERN.test(name.trim());
369379

370380
const isValid =
371-
name.trim().length > 0 &&
381+
isNameValid &&
372382
fields.length > 0 &&
373-
fields.every((f) => f.name.trim().length > 0) &&
383+
fields.every((f) => {
384+
const t = f.name.trim();
385+
return t.length > 0 && IDENTIFIER_PATTERN.test(t);
386+
}) &&
374387
!hasDuplicateNames;
375388

376389
return (
@@ -387,8 +400,19 @@ export function SchemaDialog({
387400
value={name}
388401
onChange={(e) => setName(e.target.value)}
389402
placeholder="Enter schema name"
390-
className="mt-2"
403+
className={cn(
404+
"mt-2",
405+
name.trim().length > 0 &&
406+
!IDENTIFIER_PATTERN.test(name.trim()) &&
407+
"border-destructive"
408+
)}
391409
/>
410+
{name.trim().length > 0 &&
411+
!IDENTIFIER_PATTERN.test(name.trim()) && (
412+
<p className="text-xs text-destructive mt-1">
413+
Letters, digits, and underscores only (e.g. my_schema)
414+
</p>
415+
)}
392416
</div>
393417
<div>
394418
<Label htmlFor="schema-description">Description</Label>

packages/types/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ export type FieldType =
1818
| "datetime"
1919
| "json";
2020

21+
/**
22+
* Pattern for valid identifier names (schema names, field names).
23+
* Must start with a letter or underscore, followed by letters, digits, or underscores.
24+
* Valid: firstName, first_name, FIRSTNAME
25+
* Invalid: first-name, "first name", 123abc
26+
*/
27+
export const IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
28+
2129
/**
2230
* Field definition
2331
*/

0 commit comments

Comments
 (0)