Relationships
Define associations between models using a simple DSL. SoliLang handles foreign keys, eager loading, and join filtering automatically.
Relationship DSL
Declare associations inside your model class using the built-in relationship methods:
| Method | Description |
|---|---|
has_many(name) |
Declare a one-to-many relationship |
has_one(name) |
Declare a one-to-one relationship |
belongs_to(name) |
Declare an inverse relationship |
belongs_to(name, { "polymorphic": true }) |
Belong to any of several models: stores {name}_id + {name}_type, resolved at runtime. See Polymorphic Relationships. |
has_many(name, { "as": "commentable" }) |
The inverse of a polymorphic belongs_to — queries are type-guarded to this class (also on has_one). |
has_and_belongs_to_many(name) |
Declare a many-to-many relationship through a join table |
Beyond class_name: and foreign_key: overrides, three behavioral options are available (a bad option raises at class load):
| Option | On | Description |
|---|---|---|
dependent: |
has_many, has_one | Cascade strategy on hard owner delete: "delete", "delete_all", "nullify". See Cascade Deletes. |
through: / source: |
has_many | Traverse an intermediate relation. See Through Associations. |
counter_cache: |
belongs_to | Maintain a <children>_count column on the parent (true or a custom column name). See Counter Caches. |
Defining Relationships
Add relationship declarations at the top of your model class body. Names accept strings or symbols:
class User < Model
has_many("posts") # strings and symbols both work
has_many(:posts) # Ruby-style symbol shorthand
has_one("profile")
end
class Post < Model
belongs_to("user")
has_many("comments")
end
Naming Conventions
SoliLang automatically infers the related class, collection, and foreign key from the relationship name:
| Declaration | Related Class | Collection | Foreign Key |
|---|---|---|---|
has_many("posts") |
Post | posts | user_id |
has_one("profile") |
Profile | profiles | user_id |
belongs_to("user") |
User | users | user_id |
Custom Foreign Keys
Override the default naming conventions with an options hash:
class Post < Model
belongs_to("author", { "class_name": "User", "foreign_key": "author_id" })
end
Polymorphic Relationships
A polymorphic belongs_to lets one model belong to any of several others on a single association — comments on posts and photos. The child stores commentable_id + commentable_type; the parent declares the inverse with as::
class Comment < Model
belongs_to "commentable", polymorphic: true
end
class Post < Model
has_many "comments", as: "commentable"
end
class Photo < Model
has_many "comments", as: "commentable"
end
Comment.create({
"body": "Nice shot!",
"commentable_id": photo._key,
"commentable_type": "Photo"
})
comment.commentable # → the Photo instance (class resolved from commentable_type)
post.comments # → chainable QB, type-guarded: commentable_type == "Post"
- Runtime resolution. The accessor reads both fields, resolves the class and collection from the type string, and returns the correctly-typed instance —
nullwhen either field is missing, an error naming the type when it isn't a known model class. - The
as:inverse is type-guarded everywhere — accessor,includes,includes_count,join, and cascades all carry{as}_type == "<OwnerClass>", so parents with colliding keys never see each other's children. counter_cache:works on a polymorphic belongs_to: the parent collection resolves from the type at bump time (each parent type keeps its own count; cross-type reassignment moves it), andreset_countersrecounts with the guard.dependent:works onas:relations;"nullify"clears both the FK and the type field.- Eager-loading a polymorphic belongs_to raises (
includes/join— the target collection varies per row; same restriction as Rails). Accessrecord.commentabledirectly. Theas:inverse eager-loads normally. polymorphic: true+class_name:raises at class load.
Relationship Accessors
Access related records directly from model instances. has_many
relations return a chainable QueryBuilder — not a plain array —
so you can iterate, index, or chain terminal operations like
.delete_all and .count on the same accessor.
user = User.find("user_id")
# Access has_one / belongs_to — single instance (or nil)
profile = user.profile
author = post.user
# Access has_many — chainable QueryBuilder
posts = user.posts
Writing through associations
has_many accessors (including polymorphic as: inverses) accept writes — the foreign key, and the polymorphic type when applicable, are stamped automatically. Both writers persist through the regular save path: validations, callbacks, counter caches, and dirty tracking all apply.
# Create a child through the relation — FK (and type) auto-set:
post = author.posts.create({"title": "seeded"})
post._errors # null on success, error array on validation failure
# Adopt an existing (or unpersisted) record — sets the FK and saves:
author.posts << loose_post
author.posts << [draft_a, draft_b]
# Polymorphic inverses stamp both halves of the reference:
customer.comments << Comment.create({"message": "bla"})
comment = customer.comments.create({"message": "bla"})
comment.commentable_type # "Customer"
The association seed wins over caller-supplied values; the owner must be persisted (otherwise the write raises); << expects model instances, not raw keys, and a failing save raises so the push can't silently no-op. .create on a through: relation raises — create the record and push it instead.
has_many is Enumerable AND chainable
The relation accessor behaves like an array (iteration, indexing,
len, each, map, filter, …)
and like a QueryBuilder
(.where, .order, .limit,
.count, .delete_all, .update_all, .exists, …).
Each terminal call runs a fresh query against the foreign-key filter.
# Iterate
for post in user.posts
print(post.title)
end
# Indexing materializes the result set
first = user.posts[0]
# len() / .length / .size
n = len(user.posts)
# Array-style helpers materialize then delegate
user.posts.each(fn(p) { print(p.title) })
titles = user.posts.map(fn(p) { p.title })
# Chained query — composes onto the seed user_id == @__rel_fk filter
published = user.posts.where("published = @p", { "p": true }).all
n_pub = user.posts.where("published = @p", { "p": true }).count
# Bulk delete — one REMOVE statement, no N+1
user.posts.delete_all
user.posts.where("draft = @d", { "d": true }).delete_all
# Bulk update — one UPDATE statement, no N+1
user.posts.where("draft = @d", { "d": true }).update_all({ "draft": false })
# Sort / paginate before materializing
recent = user.posts.order("created_at", "desc").limit(10).all
- An owner that has not been saved yet (no
_key) returns a QueryBuilder whose filter never matches —countis0,delete_all/update_allare no-ops, iteration yields nothing. - If the related model uses
soft_delete, soft-deleted children are excluded from the relation. Use the staticRelated.with_deleted/Related.only_deletedto query them explicitly. belongs_toandhas_onestill return a single instance (ornil), not a QueryBuilder.
Eager Loading (includes)
Without eager loading, accessing relations in a loop triggers a separate query for each record — the classic N+1 problem.
Use .includes() to preload related records in a single query via LET subqueries with MERGE:
# Load users with their posts and profiles in a single query
users = User.includes("posts", "profile").all
# Combine with where clauses
active = User.where("active = @a", { "a": true }).includes("posts").first
# Inspect the generated query
print(User.includes("posts").to_query)
has_many includes return an array. has_one and belongs_to includes return a single document (via FIRST()).
After .all, the preloaded data is cached on each instance: subsequent instance.<rel> reads return the cached value without issuing another query. This applies to has_and_belongs_to_many, belongs_to, has_one, and polymorphic relations. (has_many accessors still return a chainable QueryBuilder, so they aren't served from the preload cache — use .where(...).all if you want a materialised array.)
Join Filtering
Filter records by the existence of related records. Unlike includes, join does not preload the related data — it only filters the parent records:
# Find users who have at least one post
users_with_posts = User.join("posts").all
# Find users who have published posts
count = User.join("posts", "published = @p", { "p": true }).count
# Chain with other query methods
recent = User.join("posts").order("created_at", "desc").limit(10).all
Filtered Includes
Filter included relations to load only matching related records:
# Only load published posts for each user
users = User.includes("posts", "published = @p", { "p": true }).all
# Combine a filter with field projection using the "fields" key
users = User.includes("posts", "published = @p", {
"p": true,
"fields": ["title", "body"]
}).all
Includes with Field Projection
Use a hash argument to select specific fields on included relations (without filtering):
# Only load title and body from posts
users = User.includes({ "posts": ["title", "body"] }).all
Chaining Multiple Includes
Chain .includes() calls to eagerly load multiple relations with different options:
# Filtered posts + unfiltered profile
users = User.includes("posts", "published = @p", { "p": true })
.includes("profile")
.all
Chaining on a Loaded Relation
A has_many/has_one accessor returns a
plain array, not a query builder — the rows are already loaded. The
chainables you would reach for anyway still work on it, so a Rails-style chain reads the same whether the
ordering happened in the database or in memory:
# org.contacts is already an array
let sorted = org.contacts.order("name").all()
# descending, and rows missing the field sort first
let recent = org.contacts.order("created_at", "desc")
On an array, .order(field, dir?) sorts in memory, while
.all() and .includes(...) return it
unchanged so the chain stays readable. Rows missing the ordered field sort first, in both engines.
Counting Relations (includes_count)
When you only need the count of a relation (not the rows), .includes_count() adds a single LET _rel_<name>_count = LENGTH(...) subquery to the parent and exposes the result as a <name>_count field on each instance. Cheaper than .includes() when you only render counts:
# Each Category gets a `products_count` integer field, in one round-trip
cats = Category.includes_count("products").all
print(cats[0].products_count)
# => 3
# Combine with .includes() and other chain steps
q = Author.where("active = @a", { "a": true })
.includes("profile")
.includes_count("posts")
.order("name", "asc")
.all
Only valid for has_many and has_and_belongs_to_many relations. Calling it on belongs_to, has_one, or polymorphic relations raises an error at registration time (the count is always 0 or 1, so the API doesn't earn its keep there). The exposed field is always <relation_name>_count — reads are O(1) since it's just an integer field on the instance.
Has And Belongs To Many
Many-to-many associations use a join table that stores (<foreign_key>, <association_foreign_key>) rows. Each side of the association declares has_and_belongs_to_many:
class Post < Model
has_and_belongs_to_many("tags")
end
class Tag < Model
has_and_belongs_to_many("posts")
end
The default join table is the alphabetical concatenation of the two pluralized class names — here posts_tags. The default foreign keys are post_id and tag_id.
Reading associations
post = Post.find(post_id)
tags = post.tags # => [Tag, Tag, ...]
Adding and removing
Auto-generated mutators insert and delete join-table rows. The method name is add_<singular> / remove_<singular> derived from the relation name:
post.add_tag(tag) # accepts a Tag instance
post.add_tag("tag_key") # ...or a raw _key
post.add_tag([tag1, tag2]) # ...or an array
post.add_tag(tag1, tag2) # ...or variadic args
post.remove_tag(tag)
post.remove_tag([tag1, tag2])
Shovel operator (<<)
<< is shorthand for add_<singular> on a HABTM relation, and array push everywhere else:
post.tags << tag # equivalent to post.add_tag(tag)
nums = [1, 2, 3]
nums << 4 # array push: nums == [1, 2, 3, 4]
Eager loading
Includes use a two-stage subquery through the join table:
posts = Post.includes("tags").all
# Generated subquery:
# LET _rel_tags = (FOR jt IN posts_tags FILTER jt.post_id == doc._key
# FOR rel IN tags FILTER rel._key == jt.tag_id RETURN rel)
Existence filtering
tagged_posts = Post.join("tags").all
tutorials = Post.join("tags", "name = @n", { "n": "tutorial" }).all
Overrides
class Article < Model
has_and_belongs_to_many("labels", {
"class_name": "Tag",
"join_table": "article_labels",
"foreign_key": "article_id",
"association_foreign_key": "tag_id"
})
end
Cascade Deletes (dependent:)
Declare what happens to associated records when their owner is hard-deleted:
class User < Model
has_many "posts", dependent: "delete" # per-row: callbacks, nested cascades
has_many "events", dependent: "delete_all" # one bulk REMOVE: no callbacks
has_many "drafts", dependent: "nullify" # one bulk UPDATE: fk → null
has_one "profile", dependent: "delete"
end
- Ordering mirrors Rails.
before_deleteruns first (afalseveto aborts the cascades too), then eachdependent:relation in declaration order, then the owner row is removed, thenafter_delete. "delete"(alias"destroy") loads each child and deletes it through the interpreter — child callbacks, nested cascades, the child's own soft-delete semantics, and counter-cache decrements all apply. A child veto or error aborts the remaining cascade and the owner delete."delete_all"issues one bulk REMOVE: no callbacks, hard delete even for soft-delete child models, no nesting."nullify"issues one bulk UPDATE setting the FK to null.- Soft-delete owners never cascade — a soft
delete()keeps children, sorestore()has nothing to un-do. Model.delete(id)cascades too on classes that declaredependent:(routing through the instance flow, so delete callbacks fire as a side effect). Bulk writes (delete_all,update_all,prune) never cascade, matching Rails.- Cycles terminate: an in-flight document is skipped and recursion caps at 32 levels. No per-operation rollback — wrap in
Model.transactionfor atomicity.
Through Associations (through:)
has_many through: traverses an intermediate relation — the join-model association. The accessor returns a chainable QueryBuilder filtered by a single-query membership subquery (no N+1):
class User < Model
has_many "memberships"
has_many "teams", through: "memberships"
# source: when the name doesn't match the through model's relation
has_many("employers", {"through": "memberships", "source": "company"})
end
class Membership < Model
belongs_to "user"
belongs_to "team"
end
user.teams.where("active == @a", {"a": true}).order("name").count()
- Source inference. The relation on the through model is found by singularizing the name (
"teams"→"team"); override withsource:. Bothbelongs_tosources (join model) andhas_manysources (distant children:has_many "comments", through: "posts") work. - Soft-deleting through models automatically exclude soft-deleted join rows.
- Lazy resolution. The chain resolves at first access; a missing through/source relation raises naming exactly what was searched and suggesting
source:. - Pushing creates the join record.
user.teams << team(or<< key) inserts a through-collection row, HABTM-style — a raw join-row write (through-model validations/callbacks skipped, its counter caches bumped). Onlybelongs_tosources are writable;has_many-source pushes and unpersisted owners raise. - Bulk writes and eager-loading stay off.
delete_all/update_allraise (they would hit target rows, not join rows), and eager-loading a through relation raises.
Counter Caches (counter_cache:)
belongs_to ..., counter_cache: keeps a children count on the parent row up to date, so lists render without a COUNT query per row:
class Comment < Model
belongs_to "post", counter_cache: true # maintains posts.comments_count
belongs_to("author", {"class_name": "User", "counter_cache": "authored_count"})
end
post.comments_count # plain field read
Post.reset_counters(post._key, "comments") # recount → write → returns Int
- No schema prep.
truederives the column from the child collection (comments_count); a string picks a custom column; a missing column reads as 0. - Bumps ride the CAS loop (like
increment) and fire on child create/save, hard delete (instance and class form), FK reassignment (−1 old parent, +1 new), and soft delete/restore for soft-deleting children — counters track default-scope-visible children. - Bulk writes never bump (
delete_all/update_all/upsert/prune) and bumps are best-effort — a failing bump never fails the committed primary write.Model.reset_counters(id, relation)is the repair tool.
Manual Relationships
For more control, implement relationships as custom instance methods:
class Post < Model
def author
User.find(this.author_id)
end
end
FK Relations vs Graph Traversal
The relationships on this page are foreign-key based: a user_id field on the child points at the parent. That covers the classic parent/child shapes. When the relationship itself carries data or is naturally recursive — social graphs, org charts, "friends of friends" — an edge model with traverse fits better: edges are first-class records (with their own attributes), and multi-hop queries don't require N chained lookups.
A named graph relation is just a plain method returning the traversal builder, so callers chain it like any other relation:
class Follow < Model
edge from: "users", to: "users"
end
class User < Model
def followers()
return this.traverse(Follow, { "direction": "in" })
end
end
User.find(key).followers().where({ "active": true }).count
See Graph Models (Edges & Traversal) for the edge DSL, traversal options, and shortest_path.