ORM - Relationships
ORM - Relationships
Relationships map associations between entities — a dealership has many cars, a student enrols in many courses, an order belongs to a customer. This page covers all relationship types, bidirectional mappings, cascade behaviour, fetching strategies, and batch loading.
many-to-one
The most common relationship. The child entity holds a foreign key pointing to the parent:
// Auto.cfc — the "many" side, holds the FK
component persistent="true" accessors="true" {
property name="id" fieldtype="id" ormtype="string";
property name="make" ormtype="string";
property name="model" ormtype="string";
property name="dealer"
fieldtype="many-to-one"
cfc="Dealership"
fkcolumn="dealerID";
}
cfc— the target entity namefkcolumn— the foreign key column in this entity's table
Loading a car automatically gives you access to the dealership via car.getDealer().
many-to-one Attributes
| Attribute | Description |
|---|---|
cfc |
Target entity name (required) |
fkcolumn |
Foreign key column name |
lazy |
true (default) — creates a proxy, loads on first access. false or "no-proxy" for immediate loading |
fetch |
"select" (default) or "join" |
notnull |
true to enforce NOT NULL on the FK column |
insert |
false to exclude FK from INSERT statements |
update |
false to exclude FK from UPDATE statements |
unique |
true to add a UNIQUE constraint (effectively makes it one-to-one) |
uniquekey |
Name of a multi-column unique constraint |
index |
Creates a database index on the FK column |
missingRowIgnored |
true to return null for orphaned FKs instead of throwing |
Example with constrained FK:
property name="category"
fieldtype="many-to-one"
cfc="Category"
fkcolumn="categoryId"
notnull="true"
insert="true"
update="false";
This makes the category required and immutable after insert.
one-to-many
The parent side of a many-to-one relationship. Returns a collection (array or struct) of child entities:
// Dealership.cfc — the "one" side
component persistent="true" accessors="true" {
property name="id" fieldtype="id" ormtype="string";
property name="name" ormtype="string";
property name="address" ormtype="string";
property name="inventory"
fieldtype="one-to-many"
cfc="Auto"
fkcolumn="dealerID"
type="array"
cascade="all-delete-orphan"
inverse="true";
}
type—"array"(ordered) or"struct"(keyed by a column)cascade— what operations cascade to children. See Cascade Optionsinverse—truemeans the other side (many-to-one) owns the relationship. See Bidirectional Relationships and inverse
one-to-many Attributes
| Attribute | Description |
|---|---|
cfc |
Target entity name (required) |
fkcolumn |
Foreign key column in the child table |
type |
"array" or "struct" |
cascade |
Cascade behaviour. See Cascade Options |
inverse |
true if the other side owns the FK |
lazy |
true (default), false, or "extra" |
fetch |
"select" (default) or "join" |
batchsize |
Batch-load uninitialised collections. See Batch Fetching |
orderby |
SQL ORDER BY clause for the collection, e.g. "track_pos ASC" |
where |
SQL WHERE filter applied to the collection, e.g. "is_active = true" |
readonly |
true to make the collection read-only |
singularName |
Generates addX(), removeX(), hasX() methods. See singularName |
structKeyColumn |
Column to use as the struct key (when type="struct") |
Filtered Collections
Use the where attribute to filter which children are loaded:
// Department.cfc — only loads active staff
component persistent="true" table="departments" accessors="true" {
property name="id" fieldtype="id" ormtype="string";
property name="name" ormtype="string";
property name="activeStaff"
fieldtype="one-to-many"
cfc="Staff"
fkcolumn="deptId"
type="array"
where="is_active = true";
}
Ordered Collections
Use orderby to sort children automatically:
property name="tracks"
fieldtype="one-to-many"
cfc="Track"
fkcolumn="playlistId"
type="array"
orderby="track_pos ASC";
one-to-one
Links two entities that share a one-to-one relationship. Two approaches:
Shared Primary Key
Both entities share the same primary key value:
// Passport.cfc
component persistent="true" accessors="true" {
property name="id" fieldtype="id" ormtype="string";
property name="number" ormtype="string";
property name="holder"
fieldtype="one-to-one"
cfc="Citizen"
lazy="false";
}
Tip:
lazy="false"is recommended for one-to-one relationships because Hibernate can't proxy them reliably — it doesn't know whether the other side exists without hitting the database.
Unique Foreign Key
One side holds a FK column, creating a one-to-one via a unique foreign key constraint. Use mappedby on the non-FK side:
// Employee.cfc — holds the FK
component persistent="true" table="employees" accessors="true" {
property name="id" fieldtype="id" ormtype="string";
property name="name" ormtype="string";
property name="office"
fieldtype="one-to-one"
cfc="Office"
fkcolumn="officeId";
}
// Office.cfc — uses mappedby to point to the FK side
component persistent="true" table="offices" accessors="true" {
property name="id" fieldtype="id" ormtype="string";
property name="location" ormtype="string";
property name="employee"
fieldtype="one-to-one"
cfc="Employee"
mappedby="office";
}
Important:
mappedbyin CFML ORM is NOT the same as JPA's@MappedBy. In CFML,mappedbymeans "the FK references a unique column other than the PK in the target entity". It's used for one-to-one unique FK associations and many-to-one referencing non-PK unique columns. For standard bidirectional many-to-one/one-to-many relationships, usefkcolumn+inverse="true"instead.
many-to-many
Two entities linked through a join table:
// Student.cfc — the owning side
component persistent="true" accessors="true" {
property name="id" fieldtype="id" ormtype="string";
property name="name" ormtype="string";
property name="courses"
fieldtype="many-to-many"
cfc="Course"
linktable="student_course"
fkcolumn="studentID"
inversejoincolumn="courseID"
type="array"
lazy="true";
}
// Course.cfc — the inverse side
component persistent="true" accessors="true" {
property name="id" fieldtype="id" ormtype="string";
property name="title" ormtype="string";
property name="students"
fieldtype="many-to-many"
cfc="Student"
linktable="student_course"
fkcolumn="courseID"
inversejoincolumn="studentID"
type="array"
lazy="true"
inverse="true";
}
linktable— the join table namefkcolumn— the FK column in the join table pointing to this entityinversejoincolumn— the FK column in the join table pointing to the other entityinverse="true"— on the non-owning side (only one side should manage the join table rows)
many-to-many Attributes
| Attribute | Description |
|---|---|
cfc |
Target entity name (required) |
linktable |
Join table name (required) |
fkcolumn |
FK in join table pointing to this entity |
inversejoincolumn |
FK in join table pointing to the target entity |
type |
"array" or "struct" |
inverse |
true on the non-owning side |
lazy |
true (default), false, or "extra" |
cascade |
Cascade behaviour |
orderby |
SQL ORDER BY for the collection |
where |
SQL WHERE filter on the collection |
linkschema |
Schema for the join table (if different from the entity schema) |
linkcatalog |
Catalog for the join table |
readonly |
true for a read-only collection |
Element Collections
For simple value collections (arrays of strings, maps of key-value pairs) that don't map to another entity, use fieldtype="collection":
Array Collection (Bag)
component persistent="true" accessors="true" {
property name="id" fieldtype="id" ormtype="string";
property name="name" ormtype="string";
property name="tags"
fieldtype="collection"
type="array"
table="item_tags"
fkcolumn="parentId"
elementcolumn="tag"
elementtype="string";
}
Map Collection (Struct)
component persistent="true" accessors="true" {
property name="id" fieldtype="id" ormtype="string";
property name="name" ormtype="string";
property name="metadata"
fieldtype="collection"
type="struct"
table="item_metadata"
fkcolumn="parentId"
structKeyColumn="metaKey"
structKeyType="string"
elementcolumn="metaValue"
elementtype="string";
}
table— the collection tablefkcolumn— FK back to the parent entityelementcolumn— column holding the valueselementtype— data type of the valuesstructKeyColumn/structKeyType— for struct collections, the column and type used as the key
Bidirectional Relationships and inverse
Most real-world relationships are bidirectional — you want to navigate from parent to children and from child to parent. The critical question is: which side owns the relationship?
The Problem Without inverse
If both sides try to manage the foreign key, you get duplicate SQL:
-- Hibernate inserts the child
INSERT INTO autos (id, make, model, dealerID) VALUES (?, ?, ?, ?)
-- Then the parent ALSO updates the FK column (redundant!)
UPDATE autos SET dealerID = ? WHERE id = ?
The Fix: inverse="true"
Set inverse="true" on the non-owning side (the one-to-many or the many-to-many inverse side). This tells Hibernate: "the other side manages the FK — don't generate SQL for this side."
// Dealership.cfc — inverse=true means Auto owns the FK
property name="inventory"
fieldtype="one-to-many"
cfc="Auto"
fkcolumn="dealerID"
type="array"
cascade="all-delete-orphan"
inverse="true";
Rules of thumb:
- many-to-one / one-to-many — the many-to-one side always owns the FK. Set
inverse="true"on the one-to-many side - many-to-many — pick one side as the owner. Set
inverse="true"on the other - one-to-one — the side with the
fkcolumnowns the relationship
Cascade Options
The cascade attribute controls which operations propagate from parent to child:
| Value | Behaviour |
|---|---|
"none" |
No cascading (default). You must save/delete children explicitly |
"save-update" |
entitySave() on the parent also saves new/modified children |
"delete" |
entityDelete() on the parent also deletes children |
"all" |
Combines save-update + delete |
"all-delete-orphan" |
Like "all", plus deletes children that are removed from the collection |
"refresh" |
entityReload() on the parent also reloads children |
When to Use Each
"all-delete-orphan"— parent fully owns the children (e.g. an order owns its line items). Removing a line item from the order deletes it from the database"all"— parent manages children but children can exist independently (e.g. a dealership and its inventory)"save-update"— you want to save new children by adding them to the parent, but deleting the parent shouldn't delete children"none"— children are independently managed. You must callentitySave()andentityDelete()on each child explicitly
Warning:
"all-delete-orphan"means that removing a child from the collection deletes it from the database. If you accidentally clear the collection, all children are deleted.
Fetching Strategies
Fetching controls when and how related entities are loaded from the database.
Lazy Loading (Default)
Collections (one-to-many, many-to-many) default to lazy="true" — they load on first access:
dealer = entityLoadByPK( "Dealership", "abc" );
// No SQL for inventory yet
cars = dealer.getInventory();
// NOW the SQL runs: SELECT * FROM autos WHERE dealerID = ?
Eager Fetching with JOIN
Use fetch="join" when you always need the association — loads it in a single JOIN query:
property name="articles"
fieldtype="one-to-many"
cfc="Article"
fkcolumn="authorId"
type="array"
fetch="join";
Good for small, always-needed collections. Bad for large collections or entities where you sometimes don't need the association.
Extra Lazy
lazy="extra" is a middle ground for large collections. Calling .size() or checking .isEmpty() runs a COUNT query instead of loading the full collection. Individual items load on access:
property name="members"
fieldtype="one-to-many"
cfc="Member"
fkcolumn="groupId"
type="array"
lazy="extra";
Proxy Lazy (many-to-one / one-to-one)
many-to-one relationships default to lazy="true", which creates a proxy object. The real entity loads on the first method call (other than getId()):
article = entityLoadByPK( "Article", "123" );
// article.getAuthor() returns a proxy — no SQL yet
// article.getAuthor().getName() triggers the SELECT
Set lazy="false" to load immediately, or lazy="no-proxy" for immediate load without proxy wrapping.
The N+1 Problem
Loading a list of parents and then iterating to access a lazy relationship triggers one query per parent:
// 1 query: SELECT * FROM dealerships
dealers = entityLoad( "Dealership" );
for ( dealer in dealers ) {
// N queries: SELECT * FROM autos WHERE dealerID = ? (once per dealer!)
writeOutput( arrayLen( dealer.getInventory() ) );
}
Fixes:
- Batch fetching —
batchsizeon the relationship. See Batch Fetching - HQL JOIN FETCH —
ORMExecuteQuery( "FROM Dealership d JOIN FETCH d.inventory" ). See ORM - Querying - Eager fetch —
fetch="join"if you always need the association - Diagnosis — enable
logSQL: truein ORM - Configuration. If you see the same SELECT repeated with different IDs, you've got N+1
Batch Fetching
Batch fetching is the most practical N+1 fix. Instead of loading one collection at a time, Hibernate loads multiple uninitialised collections in a single query.
On Relationships
property name="books"
fieldtype="one-to-many"
cfc="Book"
fkcolumn="publisherId"
type="array"
lazy="true"
batchsize="10";
If you load 25 publishers and access the first one's books, Hibernate loads books for 10 publishers in one query. That's 3 queries (10 + 10 + 5) instead of 25.
On Entities
Set batchsize on the component to batch-load proxied entity instances:
component persistent="true" accessors="true" batchsize="5" {
// ...
}
When Hibernate needs to resolve a proxy for this entity type, it loads up to 5 at once.
singularName
The singularName attribute generates convenience methods for collection management:
// Library.cfc
property name="books"
singularName="book"
fieldtype="one-to-many"
cfc="LibBook"
fkcolumn="libraryId"
type="array"
cascade="all";
This generates:
addBook( book )— adds a book to the collectionremoveBook( book )— removes a book from the collectionhasBook( book )— checks if a book is in the collection
library = entityLoadByPK( "Library", "abc" );
book = entityNew( "LibBook", { id: createUUID(), title: "CFML in Action" } );
library.addBook( book );
entitySave( library );
missingRowIgnored
When a foreign key points to a row that no longer exists in the target table (orphaned FK), Hibernate normally throws an exception. Set missingRowIgnored="true" to return null instead:
property name="parent"
fieldtype="many-to-one"
cfc="Parent"
fkcolumn="parentId"
missingRowIgnored="true";
Applies to many-to-one, one-to-one, and many-to-many.
Common Mistakes
Collection Replacement Trap
Don't replace a collection — modify it in place:
// BAD — Hibernate loses track of the proxied collection
entity.setChildren( newArray );
// GOOD — modify the existing collection
entity.getChildren().clear();
entity.getChildren().addAll( newArray );
When you replace the collection object, Hibernate can't track what changed and recreates the entire thing (DELETE all + INSERT all).
Missing inverse
Without inverse="true" on the one-to-many side of a bidirectional relationship, every save generates redundant UPDATE statements. Your data is correct but you're doing twice the SQL.
Cascade Without inverse
If you set cascade="all-delete-orphan" on a one-to-many but forget inverse="true", Hibernate tries to null out the FK column before deleting the child row — which fails if the FK has a NOT NULL constraint.
What's Next?
- ORM - Querying — HQL with JOIN FETCH to solve N+1 at query time
- ORM - Sessions and Transactions — how flush timing affects relationship persistence
- ORM - Troubleshooting — "unsaved transient instance", "collection was not an association", and other relationship errors