Skip to content

Commit 104b225

Browse files
brainkimclaude
andcommitted
feat: validate compound constraints have 2+ fields
- Compound indexes, unique constraints, and foreign keys must have at least 2 fields - Throws TableDefinitionError with helpful message pointing to field-level API - Added tests for singleton constraint validation Also: README updates (Quick Start migration pattern, default values docs, query builder section, unique constraints, various cleanups) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent fe77e79 commit 104b225

File tree

3 files changed

+145
-58
lines changed

3 files changed

+145
-58
lines changed

README.md

Lines changed: 60 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@ db.addEventListener("upgradeneeded", (e) => {
7777
id: z.string().uuid().db.primary().db.auto(),
7878
email: z.string().email().db.unique(),
7979
name: z.string(),
80-
avatar: z.string().optional(), // new field
80+
avatar: z.string().optional(), // new field
8181
});
82-
await db.ensureTable(Users); // adds missing columns/indexes
83-
await db.ensureConstraints(Users); // applies new constraints on existing data
82+
await db.ensureTable(Users); // adds missing columns/indexes
83+
await db.ensureConstraints(Users); // applies new constraints on existing data
8484
}
8585
})());
8686
});
@@ -205,7 +205,7 @@ const Users = table("users", {
205205

206206
// id and createdAt are optional - auto-generated if not provided
207207
const user = await db.insert(Users, {name: "Alice"});
208-
user.id; // "550e8400-e29b-41d4-a716-446655440000"
208+
user.id; // "550e8400-e29b-41d4-a716-446655440000"
209209
user.createdAt; // 2024-01-15T10:30:00.000Z
210210
```
211211

@@ -256,7 +256,7 @@ const settings = await db.insert(Settings, {
256256

257257
// On read: JSON strings are parsed back to objects/arrays
258258
settings.config.theme; // "dark" (object, not string)
259-
settings.tags[0]; // "admin" (array, not string)
259+
settings.tags[0]; // "admin" (array, not string)
260260
```
261261

262262
**Custom encoding/decoding:**
@@ -319,14 +319,16 @@ const posts = await db.all([Posts, Users.active])`
319319
`;
320320
```
321321

322-
**Compound indexes** via table options:
322+
**Compound indexes and unique constraints** via table options:
323323
```typescript
324324
const Posts = table("posts", {
325325
id: z.string().db.primary(),
326326
authorId: z.string(),
327+
slug: z.string(),
327328
createdAt: z.date(),
328329
}, {
329330
indexes: [["authorId", "createdAt"]],
331+
unique: [["authorId", "slug"]], // unique together
330332
});
331333
```
332334

@@ -373,16 +375,16 @@ type Post = Row<typeof Posts>;
373375
// Post includes: id, title, authorId, titleUpper, tags
374376

375377
const posts = await db.all([Posts, Users, PostTags, Tags])`
376-
JOIN "users" ON ${Users.on(Posts)}
377-
LEFT JOIN "post_tags" ON ${PostTags.cols.postId} = ${Posts.cols.id}
378-
LEFT JOIN "tags" ON ${Tags.on(PostTags)}
378+
JOIN ${Users} ON ${Users.on(Posts)}
379+
LEFT JOIN ${PostTags} ON ${PostTags.cols.postId} = ${Posts.cols.id}
380+
LEFT JOIN ${Tags} ON ${Tags.on(PostTags)}
379381
`;
380382

381383
const post = posts[0];
382384
post.titleUpper; // "HELLO WORLD" — typed as string
383-
post.tags; // ["javascript", "typescript"] — traverses relationships
384-
Object.keys(post); // ["id", "title", "authorId", "author"] (no derived props)
385-
JSON.stringify(post); // Excludes derived properties (non-enumerable)
385+
post.tags; // ["javascript", "typescript"] — traverses relationships
386+
Object.keys(post); // ["id", "title", "authorId", "author"] (no derived props)
387+
JSON.stringify(post); // Excludes derived properties (non-enumerable)
386388
```
387389

388390
Derived properties:
@@ -394,9 +396,9 @@ Derived properties:
394396

395397
**Partial selects** with `pick()`:
396398
```typescript
397-
const UserSummary = Users.pick("id", "name");
399+
const UserSummaries = Users.pick("id", "name");
398400
const posts = await db.all([Posts, UserSummary])`
399-
JOIN "users" ON ${UserSummary.on(Posts)}
401+
JOIN ${UserSummaries} ON ${UserSummary.on(Posts)}
400402
`;
401403
// posts[0].author has only id and name
402404
```
@@ -989,41 +991,41 @@ Override with `.db.type("CUSTOM")` when using custom encode/decode.
989991
```typescript
990992
import {
991993
// Zod (extended with .db namespace)
992-
z, // Re-exported Zod with .db already available
994+
z, // Re-exported Zod with .db already available
995+
extendZod, // Extend a separate Zod instance (advanced)
993996

994997
// Table and view definition
995-
table, // Create a table definition from Zod schema
996-
view, // Create a read-only view from a table
997-
isTable, // Type guard for Table objects
998-
isView, // Type guard for View objects
999-
extendZod, // Extend a separate Zod instance (advanced)
998+
table, // Create a table definition from Zod schema
999+
view, // Create a read-only view from a table
1000+
isTable, // Type guard for Table objects
1001+
isView, // Type guard for View objects
10001002

10011003
// Database
1002-
Database, // Main database class
1003-
Transaction, // Transaction context (passed to transaction callbacks)
1004-
DatabaseUpgradeEvent, // Event object for "upgradeneeded" handler
1004+
Database, // Main database class
1005+
Transaction, // Transaction context (passed to transaction callbacks)
1006+
DatabaseUpgradeEvent, // Event object for "upgradeneeded" handler
10051007

10061008
// SQL builtins (for .db.inserted() / .db.updated())
1007-
NOW, // CURRENT_TIMESTAMP alias
1008-
TODAY, // CURRENT_DATE alias
1009-
CURRENT_TIMESTAMP, // SQL CURRENT_TIMESTAMP
1010-
CURRENT_DATE, // SQL CURRENT_DATE
1011-
CURRENT_TIME, // SQL CURRENT_TIME
1009+
NOW, // CURRENT_TIMESTAMP alias
1010+
TODAY, // CURRENT_DATE alias
1011+
CURRENT_TIMESTAMP, // SQL CURRENT_TIMESTAMP
1012+
CURRENT_DATE, // SQL CURRENT_DATE
1013+
CURRENT_TIME, // SQL CURRENT_TIME
10121014

10131015
// Errors
1014-
DatabaseError, // Base error class
1015-
ValidationError, // Schema validation failed
1016-
TableDefinitionError, // Invalid table definition
1017-
MigrationError, // Migration failed
1018-
MigrationLockError, // Failed to acquire migration lock
1019-
QueryError, // SQL execution failed
1020-
NotFoundError, // Entity not found
1021-
AlreadyExistsError, // Unique constraint violated
1016+
DatabaseError, // Base error class
1017+
ValidationError, // Schema validation failed
1018+
TableDefinitionError, // Invalid table definition
1019+
MigrationError, // Migration failed
1020+
MigrationLockError, // Failed to acquire migration lock
1021+
QueryError, // SQL execution failed
1022+
NotFoundError, // Entity not found
1023+
AlreadyExistsError, // Unique constraint violated
10221024
ConstraintViolationError, // Database constraint violated
1023-
ConnectionError, // Connection failed
1024-
TransactionError, // Transaction failed
1025-
isDatabaseError, // Type guard for DatabaseError
1026-
hasErrorCode, // Check error code
1025+
ConnectionError, // Connection failed
1026+
TransactionError, // Transaction failed
1027+
isDatabaseError, // Type guard for DatabaseError
1028+
hasErrorCode, // Check error code
10271029
} from "@b9g/zen";
10281030
```
10291031

@@ -1032,34 +1034,34 @@ import {
10321034
```typescript
10331035
import type {
10341036
// Table types
1035-
Table, // Table definition object
1036-
PartialTable, // Table created via .pick()
1037-
DerivedTable, // Table with derived fields via .derive()
1038-
TableOptions, // Options for table()
1039-
ReferenceInfo, // Foreign key reference metadata
1040-
CompoundReference, // Compound foreign key reference
1037+
Table, // Table definition object
1038+
PartialTable, // Table created via .pick()
1039+
DerivedTable, // Table with derived fields via .derive()
1040+
TableOptions, // Options for table()
1041+
ReferenceInfo, // Foreign key reference metadata
1042+
CompoundReference, // Compound foreign key reference
10411043

10421044
// Field types
1043-
FieldMeta, // Field metadata for form generation
1044-
FieldType, // Field type enum
1045-
FieldDBMeta, // Database-specific field metadata
1045+
FieldMeta, // Field metadata for form generation
1046+
FieldType, // Field type enum
1047+
FieldDBMeta, // Database-specific field metadata
10461048

10471049
// Type inference
1048-
Row, // Infer row type from Table (after read)
1049-
Insert, // Infer insert type from Table (respects defaults/.db.auto())
1050-
Update, // Infer update type from Table (all fields optional)
1050+
Row, // Infer row type from Table (after read)
1051+
Insert, // Infer insert type from Table (respects defaults/.db.auto())
1052+
Update, // Infer update type from Table (all fields optional)
10511053

10521054
// Fragment types
1053-
SetValues, // Values accepted by Table.set()
1054-
SQLTemplate, // SQL template object (return type of set(), on(), etc.)
1055-
SQLDialect, // "sqlite" | "postgresql" | "mysql"
1055+
SetValues, // Values accepted by Table.set()
1056+
SQLTemplate, // SQL template object (return type of set(), on(), etc.)
1057+
SQLDialect, // "sqlite" | "postgresql" | "mysql"
10561058

10571059
// Driver types
1058-
Driver, // Driver interface for adapters
1059-
TaggedQuery, // Tagged template query function
1060+
Driver, // Driver interface for adapters
1061+
TaggedQuery, // Tagged template query function
10601062

10611063
// Error types
1062-
DatabaseErrorCode, // Error code string literals
1064+
DatabaseErrorCode, // Error code string literals
10631065
} from "@b9g/zen";
10641066
```
10651067

src/impl/table.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1644,6 +1644,36 @@ export function table<
16441644
);
16451645
}
16461646

1647+
// Validate compound indexes have 2+ fields (use .db.index() for single fields)
1648+
for (const idx of options.indexes ?? []) {
1649+
if (idx.length < 2) {
1650+
throw new TableDefinitionError(
1651+
`Compound index in table "${name}" must have at least 2 fields. Use .db.index() for single-field indexes.`,
1652+
name,
1653+
);
1654+
}
1655+
}
1656+
1657+
// Validate compound unique constraints have 2+ fields (use .db.unique() for single fields)
1658+
for (const u of options.unique ?? []) {
1659+
if (u.length < 2) {
1660+
throw new TableDefinitionError(
1661+
`Compound unique constraint in table "${name}" must have at least 2 fields. Use .db.unique() for single-field constraints.`,
1662+
name,
1663+
);
1664+
}
1665+
}
1666+
1667+
// Validate compound foreign keys have 2+ fields (use .db.references() for single fields)
1668+
for (const ref of options.references ?? []) {
1669+
if (ref.fields.length < 2) {
1670+
throw new TableDefinitionError(
1671+
`Compound foreign key in table "${name}" must have at least 2 fields. Use .db.references() for single-field foreign keys.`,
1672+
name,
1673+
);
1674+
}
1675+
}
1676+
16471677
// Extract Zod schemas and metadata from .meta()
16481678
const zodShape: Record<string, ZodType> = {};
16491679
const meta = {

test/table.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,61 @@ describe("table", () => {
195195
expect(logs.primaryKey()).toBe(null);
196196
});
197197

198+
test("singleton compound index throws error", () => {
199+
expect(() =>
200+
table(
201+
"posts",
202+
{
203+
id: z.string().db.primary(),
204+
authorId: z.string(),
205+
},
206+
{
207+
indexes: [["authorId"]], // only 1 field - should throw
208+
},
209+
),
210+
).toThrow(/must have at least 2 fields.*\.db\.index\(\)/);
211+
});
212+
213+
test("singleton compound unique throws error", () => {
214+
expect(() =>
215+
table(
216+
"posts",
217+
{
218+
id: z.string().db.primary(),
219+
slug: z.string(),
220+
},
221+
{
222+
unique: [["slug"]], // only 1 field - should throw
223+
},
224+
),
225+
).toThrow(/must have at least 2 fields.*\.db\.unique\(\)/);
226+
});
227+
228+
test("singleton compound foreign key throws error", () => {
229+
const Users = table("users", {
230+
id: z.string().db.primary(),
231+
});
232+
233+
expect(() =>
234+
table(
235+
"posts",
236+
{
237+
id: z.string().db.primary(),
238+
authorId: z.string(),
239+
},
240+
{
241+
references: [
242+
{
243+
fields: ["authorId"], // only 1 field - should throw
244+
table: Users,
245+
as: "author",
246+
},
247+
],
248+
},
249+
),
250+
).toThrow(/must have at least 2 fields.*\.db\.references\(\)/);
251+
});
252+
198253
test("extracts Zod 4 .meta() for UI metadata", () => {
199254
const users = table("users", {
200255
id: z.string().uuid().db.primary(),

0 commit comments

Comments
 (0)