Problem
When a model defines belongsTo: ['User', 'Coach'], the migration codegen auto-generates FK constraint migrations for user_id and coach_id. However, it applies the same NOT NULL + FK constraint logic to both, ignoring the attribute-level configuration where coach_id is explicitly marked as optional() / nullable.
Example model
export default defineModel({
name: 'Athlete',
table: 'athletes',
belongsTo: ['User', 'Coach'],
attributes: {
user_id: {
fillable: true,
validation: { rule: schema.integer().required() },
},
coach_id: {
fillable: true,
validation: { rule: schema.integer().optional() }, // <-- should be nullable
},
},
})Current behavior
The migration codegen:
- Sees
belongsTo: ['Coach'] - Auto-generates
ALTER TABLE athletes ADD CONSTRAINT athletes_coach_id_fk FOREIGN KEY (coach_id) REFERENCES coaches(id)— with implicit NOT NULL - Ignores that the attribute says
optional() - There's no way to specify
ON DELETE SET NULLvsON DELETE CASCADE - There's no way to specify a non-conventional FK column name (e.g.,
assigned_coach_idinstead ofcoach_id)
Additional issues
schema.number()generatesREALcolumns for FK fields in SQLite when it should useINTEGER. Workaround: useschema.integer()instead. (Separate issue but related — FK columns need correct types.)- Duplicate column generation: If the FK column is already defined in
attributes, the migration codegen still tries to create/alter it from thebelongsTodefinition, causing conflicts.
Proposed solution
Separate schema concerns from query concerns
belongsTo should be a pure ORM/query concern — it tells the query builder how to join tables and eager-load relationships. It should not auto-generate schema/migration SQL when the FK column is already explicitly defined in attributes.
Add foreignKey property to attribute definitions
Allow explicit FK constraint configuration on the attribute itself:
attributes: {
user_id: {
fillable: true,
validation: { rule: schema.integer().required() },
foreignKey: {
table: 'users',
column: 'id',
onDelete: 'cascade',
nullable: false,
},
},
coach_id: {
fillable: true,
validation: { rule: schema.integer().optional() },
foreignKey: {
table: 'coaches',
column: 'id',
onDelete: 'set null',
nullable: true,
},
},
}Migration codegen behavior
- If a
belongsToFK column exists inattributes: skip auto-FK generation frombelongsTo. Read FK config from the attribute'sforeignKeyproperty instead. - If a
belongsToFK column does NOT exist inattributes: auto-generate it as today (backward-compatible). foreignKey.nullable: controls whether the column isNOT NULLor nullable.foreignKey.onDelete: generatesON DELETE CASCADE,ON DELETE SET NULL,ON DELETE RESTRICT, orON DELETE NO ACTION.foreignKey.table+foreignKey.column: allows non-conventional FK naming —assigned_coach_idpointing tocoaches.id.
Generated migration example
For the model above, the codegen would produce:
-- For user_id (required, cascade)
CREATE TABLE IF NOT EXISTS "athletes" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"coach_id" INTEGER REFERENCES "coaches"("id") ON DELETE SET NULL,
...
);For ALTER migrations (adding FK to existing table):
-- PostgreSQL / MySQL
ALTER TABLE "athletes" ADD CONSTRAINT "athletes_coach_id_fk"
FOREIGN KEY ("coach_id") REFERENCES "coaches"("id") ON DELETE SET NULL;
-- SQLite (only in CREATE TABLE, not ALTER — skip gracefully)foreignKey interface
interface ForeignKeyConfig {
/** Referenced table name */
table: string
/** Referenced column (defaults to 'id') */
column?: string
/** ON DELETE behavior */
onDelete?: 'cascade' | 'set null' | 'restrict' | 'no action'
/** ON UPDATE behavior */
onUpdate?: 'cascade' | 'set null' | 'restrict' | 'no action'
/** Whether the FK column allows NULL */
nullable?: boolean
}Why this matters
- Real-world models have nullable FKs — an athlete may or may not have a coach assigned. Forcing NOT NULL breaks the data model.
- Cascade behavior varies — deleting a coach should SET NULL on athletes (don't delete the athlete), but deleting a user should CASCADE (delete the athlete profile too).
- Non-conventional FK names are common —
primary_coach_id,backup_coach_id,created_by_user_id, etc. - Explicit > implicit — the developer already defines the column in
attributes. The migration codegen should use that as the source of truth, not override it frombelongsTo.
Comparison with other ORMs
| Feature | Laravel/Eloquent | Prisma | bun-query-builder (current) | Proposed |
|---|---|---|---|---|
| Nullable FK | ->nullable() | Coach? @relation | Not supported | foreignKey.nullable |
| ON DELETE | ->cascadeOnDelete() | onDelete: SetNull | Not configurable | foreignKey.onDelete |
| Custom FK name | ->constrained('coaches', 'id') | @relation(fields: [...]) | Convention only | foreignKey.table/column |
| Schema vs query separation | Migrations vs Eloquent relations | Schema vs @relation | Mixed in belongsTo | attributes.foreignKey vs belongsTo |