Relations
Tables point at each other through foreign keys: a posts.user_id column holds the id of the user who wrote the post. A relation is a model method that describes such a link, so you can write user.posts().get() instead of a hand-written query.
You define a relation as a public method with no parameters, named after the relation, that returns one of four relation types. The method name matters: eager loading finds relations by calling that method by name.
| Relation | Shape | Example | Resolves to |
|---|---|---|---|
hasOne |
the other table holds our key, at most one row | user → profile | R? |
hasMany |
the other table holds our key, many rows | user → posts | List<R> |
belongsTo |
we hold the other table’s key | post → user | R? |
belongsToMany |
a third (pivot) table holds both keys | user ↔ roles | List<R> |
morphMany / morphOne |
related rows store {name}_type + {name}_id |
post → comments | List<R> / R? |
morphToMany / morphedByMany |
pivot stores {name}_type + {name}_id + related key |
post ↔ tags | List<R> |
morphTo |
this row stores type + id of the owner | comment → post/video | Cadence<*>? |
hasManyThrough / hasOneThrough |
distant rows via an intermediate table | user → comments via posts | List<R> / R? |
Defining relations
Section titled “Defining relations”import org.konaframework.cadence.Cadenceimport org.konaframework.cadence.CadenceCompanionimport org.konaframework.cadence.relations.BelongsToimport org.konaframework.cadence.relations.BelongsToManyimport org.konaframework.cadence.relations.HasManyimport org.konaframework.cadence.relations.HasOne
class User : Cadence<User>() { fun profile(): HasOne<Profile> = hasOne(Profile::class.java) fun posts(): HasMany<Post> = hasMany(Post::class.java) fun roles(): BelongsToMany<Role> = belongsToMany(Role::class.java)
companion object : CadenceCompanion<User>(User::class.java)}
class Post : Cadence<Post>() { override val fillable = setOf("title")
fun user(): BelongsTo<User> = belongsTo(User::class.java) fun comments(): HasMany<Comment> = hasMany(Comment::class.java).apply { orderBy("created_at") }
companion object : CadenceCompanion<Post>(Post::class.java)}import org.konaframework.cadence.Cadence;import org.konaframework.cadence.CadenceCompanion;import org.konaframework.cadence.relations.BelongsTo;import org.konaframework.cadence.relations.BelongsToMany;import org.konaframework.cadence.relations.HasMany;import org.konaframework.cadence.relations.HasOne;
public class User extends Cadence<User> { public static final CadenceCompanion<User> QUERY = Cadence.of(User.class);
public HasOne<Profile> profile() { return hasOne(Profile.class); } public HasMany<Post> posts() { return hasMany(Post.class); } public BelongsToMany<Role> roles() { return belongsToMany(Role.class); }}
public class Post extends Cadence<Post> { public static final CadenceCompanion<Post> QUERY = Cadence.of(Post.class);
public BelongsTo<User> user() { return belongsTo(User.class); } public HasMany<Comment> comments() { HasMany<Comment> relation = hasMany(Comment.class); relation.orderBy("created_at", "asc"); return relation; }}A constraint added inside the definition (like the orderBy above) applies every time the relation runs, lazily or eagerly.
Key conventions
Section titled “Key conventions”Key names are derived from class names and primary keys. Pass explicit names when your columns differ.
| Call | Defaults | Full form |
|---|---|---|
hasOne(Profile) / hasMany(Post) |
foreign key user_id on the related table; local key id |
hasMany(Post::class.java, "author_id", "id") |
belongsTo(User) |
foreign key user_id on this table; owner key id on users; relation name user |
belongsTo(User::class.java, "author_id", "id", "author") |
belongsToMany(Role) |
pivot table role_user (both snake names, sorted, joined with _); pivot keys user_id and role_id; parent and related keys id |
belongsToMany(Role::class.java, "user_roles", "user_id", "role_id", "id", "id") |
The prefix is the snake-case class name: BlogPost gives blog_post_id.
Lazy loading
Section titled “Lazy loading”Calling a relation method returns a fresh relation object. Nothing runs until you ask for results:
val user = User.findOrFail(1)
val posts: List<Post> = user.posts().get()val published = user.posts().where("published", true).orderBy("created_at", "desc").get()val profile: Profile? = user.profile().first()val author: User? = post.user().first()val howMany: Long = user.posts().count()val any: Boolean = user.roles().exists()User user = User.QUERY.findOrFail(1);
List<Post> posts = user.posts().get();List<Post> published = user.posts().where("published", true).orderBy("created_at", "desc").get();Profile profile = user.profile().first();User author = post.user().first();long howMany = user.posts().count();boolean any = user.roles().exists();Every relation supports get() (all rows, also for to-one relations), first(), count(), exists(), getResults() (the natural value: a list or a single model), and the constraint helpers where, orderBy, and constrain { builder -> … } for anything else on the related ModelBuilder.
When the parent has no key yet (an unsaved model), relations return an empty list or null without querying.
Each call to user.posts() runs a new query. To keep a loaded value on the model, use getRelationValue: it loads the relation the first time and returns the stored value afterwards.
val posts = user.getRelationValue<List<Post>>("posts") // queriesval again = user.getRelationValue<List<Post>>("posts") // no queryEager loading
Section titled “Eager loading”Looping over 50 users and calling user.posts().get() for each runs 51 queries: one for the users and one per user. This is the N+1 problem. Eager loading fixes it by loading the relation for all users at once: one query for the users, then one query for all their posts (where user_id in (…)), whose rows are handed out to the right users in memory.
val users = User.with("posts", "profile").get() // 3 queries in totalfor (user in users) { val posts = user.getRelation("posts") as List<*> // already loaded}
User.query().with("posts.comments").get() // nested: users, posts, comments — 3 queriesList<User> users = User.QUERY.with("posts", "profile").get();for (User user : users) { List<?> posts = (List<?>) user.getRelation("posts");}
User.QUERY.query().with("posts.comments").get();- Names are relation method names; dots load relations of the related models (
posts.comments,posts.user). - After loading, each model has the relation stored: a list for to-many relations (empty when there are no rows) or a model /
nullfor to-one relations. Read it withgetRelation(name)orgetRelationValue(name); check withrelationLoaded(name). withworks withget(),first(),find(),findMany(), andpaginate().toMap()includes loaded relations, rendered recursively, soResponse.json(user.toMap())returns the user with its posts.
Constraining an eager load
Section titled “Constraining an eager load”Pass a RelationConstraint to filter or sort what is loaded:
import org.konaframework.cadence.relations.RelationConstraint
val users = User.query() .with("posts", RelationConstraint { it.where("published", true).orderBy("created_at", "desc") }) .get()List<User> users = User.QUERY.query() .with("posts", relation -> relation.where("published", true).orderBy("created_at", "desc")) .get();Loading onto models you already have
Section titled “Loading onto models you already have”user.load("posts", "roles") // always queries, replacing loaded valuesuser.loadMissing("posts", "profile") // only relations not loaded yetEagerLoader.load(users, "posts") // a list loaded elsewhere, still one query per relationAsking for a name that is not a public no-argument method returning a relation throws CadenceException (“Call to undefined relationship”).
Querying relationship existence
Section titled “Querying relationship existence”Filter parents by whether related rows exist (translated to EXISTS / NOT EXISTS subqueries):
| Call | Meaning |
|---|---|
whereHas("posts") / has("posts") |
at least one related row |
whereDoesntHave("posts") / doesntHave("posts") |
no related rows |
orWhereHas / orWhereDoesntHave |
same checks joined with or |
whereHas("posts") { it.where("published", true) } |
existence with extra constraints on the related query |
val authors = User.whereHas("posts") { it.where("published", true) }.get()val newcomers = User.doesntHave("posts").get()List<User> authors = User.QUERY.whereHas("posts", q -> q.where("published", true)).get();List<User> newcomers = User.QUERY.doesntHave("posts").get();Works for hasOne / hasMany, belongsTo, and belongsToMany (including the pivot join).
Counting, existence, and sums
Section titled “Counting, existence, and sums”withCount("posts") adds a posts_count attribute (a subquery) without loading the related rows. Use relation as alias for a custom attribute name:
val users = User.withCount("posts", "posts as published_posts_count").get()users[0]["posts_count"]users[0]["published_posts_count"]
// After the fact:user.loadCount("posts", "roles")withExists("posts") adds a boolean-ish posts_exists column (1 / 0). Aggregate helpers share the same relation:column / relation as alias:column shape:
| Helper | Default alias | Loader |
|---|---|---|
withSum("posts", "votes") |
posts_sum_votes |
loadSum |
withAvg("posts", "votes") |
posts_avg_votes |
loadAvg |
withMax / withMin |
posts_max_votes / posts_min_votes |
loadMax / loadMin |
val authors = Author.withExists("books") .withSum("books", "votes") .withAvg("books", "votes") .withMax("books", "votes") .get()author.loadExists("books")author.loadSum("books as total_votes:votes")author.loadAvg("books:votes")MorphTo existence (comment.whereHas("commentable")) ORs across registered MorphMap aliases. BelongsToMany supports firstOrCreate(attributes, values) (find attached related row, or create + attach).
Combine with select("users.*") (or let these helpers ensure a table select) when you also project specific columns.
Writing through relations
Section titled “Writing through relations”hasOne / hasMany
Section titled “hasOne / hasMany”create, make, and save set the foreign key for you:
val post = user.posts().create(mapOf("title" to "Hello")) // fill (fillable rules apply) + user_id + saveval draft = user.posts().make(mapOf("title" to "Draft")) // same, unsaveduser.posts().save(Post().forceFill(mapOf("title" to "Imported")))user.posts().createMany(listOf(mapOf("title" to "A"), mapOf("title" to "B")))
// Find under this relation's scope, or create / update linked to the parent:user.posts().firstOrCreate(mapOf("title" to "Hello"))user.posts().firstOrCreate(mapOf("slug" to "hello"), mapOf("title" to "Hello"))user.posts().updateOrCreate(mapOf("slug" to "hello"), mapOf("title" to "Hello again"))user.posts().firstOrNew(mapOf("title" to "Draft")) // unsaved when missingPost post = user.posts().create(Map.of("title", "Hello"));Post draft = user.posts().make(Map.of("title", "Draft"));user.posts().save(new Post().forceFill(Map.of("title", "Imported")));user.posts().createMany(List.of(Map.of("title", "A"), Map.of("title", "B")));
user.posts().firstOrCreate(Map.of("title", "Hello"));user.posts().firstOrCreate(Map.of("slug", "hello"), Map.of("title", "Hello"));user.posts().updateOrCreate(Map.of("slug", "hello"), Map.of("title", "Hello again"));user.posts().firstOrNew(Map.of("title", "Draft"));These throw IllegalStateException when the parent has not been saved yet (it has no key to copy). firstOrCreate / updateOrCreate / firstOrNew search only among rows already linked to the parent (and set the foreign key — and morph type for polymorphic relations — when creating).
belongsTo
Section titled “belongsTo”post.user().associate(otherUser) // sets user_id and stores the "user" relationpost.save()post.user().dissociate() // sets user_id to nullpost.save()belongsToMany
Section titled “belongsToMany”Pivot rows are managed with attach, detach, and sync. Each accepts keys or model instances.
user.roles().attach(admin) // one rowuser.roles().attach(listOf(1, 2), mapOf("granted_by" to 7)) // extra pivot columnsuser.roles().detach(admin) // returns rows removeduser.roles().detach() // remove allval result = user.roles().sync(listOf(1, 3)) // exactly theseresult.attached // [3]result.detached // [2]user.roles().syncWithoutDetaching(listOf(4, 5)) // attach missing onlyuser.roles().toggle(listOf(1, 6)) // attach missing, detach presentuser.roles().updateExistingPivot(1, mapOf("granted_by" to 9))user.roles().wherePivot("granted_by", 7).get() // filter via pivot columnsuser.roles().wherePivotIn("role_id", listOf(1, 2)).get()user.roles().attach(admin);user.roles().attach(List.of(1, 2), Map.of("granted_by", 7));user.roles().detach(admin);user.roles().detach();SyncResult result = user.roles().sync(List.of(1, 3));result.getAttached();result.getDetached();user.roles().syncWithoutDetaching(List.of(4, 5));user.roles().toggle(List.of(1, 6));user.roles().updateExistingPivot(1, Map.of("granted_by", 9));user.roles().wherePivot("granted_by", 7).get();user.roles().wherePivotIn("role_id", List.of(1, 2)).get();Pivot data
Section titled “Pivot data”Every model returned by a belongsToMany relation carries its pivot row as the pivot relation: a map holding the two pivot keys plus any columns named in withPivot. withTimestamps() fills created_at / updated_at on attach (and updated_at on updateExistingPivot) and reads them into pivot.
fun roles(): BelongsToMany<Role> = belongsToMany(Role::class.java).withPivot("granted_by").withTimestamps()
val role = user.roles().first()!!val pivot = role.getRelation("pivot") as Map<*, *>pivot["granted_by"]pivot["created_at"]Soft-deleted related rows are excluded from every relation unless the related query asks otherwise (constrain { it.withTrashed() }).
Polymorphic relations
Section titled “Polymorphic relations”When one table can belong to several parent types (comments on posts or videos), store {name}_type and {name}_id on the child. Schema helpers morphs, nullableMorphs, and uuidMorphs create those columns (and an index).
Register short aliases with Relation.morphMap so the type column stays stable:
Relation.morphMap( mapOf( "posts" to Post::class.java, "videos" to Video::class.java, ),)
class Post : Cadence<Post>() { fun comments(): MorphMany<Comment> = morphMany(Comment::class.java, "commentable") fun image(): MorphOne<Image> = morphOne(Image::class.java, "imageable")}
class Comment : Cadence<Comment>() { fun commentable(): MorphTo = morphTo("commentable")}
post.comments().create(mapOf("body" to "Nice"))comment.commentable().getResults() // Post or Videocomment.commentable().associate(post)Without a morph map entry, Cadence uses the model’s simple class name (and can resolve a fully-qualified class name stored in the type column).
morphToMany / morphedByMany
Section titled “morphToMany / morphedByMany”Polymorphic many-to-many uses a pivot with {name}_id, {name}_type, and the related foreign key (default table name is the plural of {name}):
class Post : Cadence<Post>() { fun tags(): MorphToMany<Tag> = morphToMany(Tag::class.java, "taggable")}
class Tag : Cadence<Tag>() { fun posts(): MorphToMany<Post> = morphedByMany(Post::class.java, "taggable") fun videos(): MorphToMany<Video> = morphedByMany(Video::class.java, "taggable")}
// taggables(tag_id, taggable_id, taggable_type)post.tags().attach(listOf(kotlinTag, javaTag))tag.posts().get()attach / detach / sync / withPivot / withTimestamps behave like belongsToMany, and every pivot write includes the morph type. Eager load with Post.with("tags").
Through relations
Section titled “Through relations”hasManyThrough / hasOneThrough reach a distant table through an intermediate model (user → posts → comments):
class User : Cadence<User>() { fun comments(): HasManyThrough<Comment, Post> = hasManyThrough(Comment::class.java, Post::class.java, "user_id", "post_id")
fun firstComment(): HasOneThrough<Comment, Post> = hasOneThrough(Comment::class.java, Post::class.java, "user_id", "post_id")}
user.comments().get()User.with("comments").get()Default foreign keys follow {snake_class}_id on each side; pass explicit keys when your schema uses shorter names.