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 }) |
Declare a polymorphic relationship |
has_and_belongs_to_many(name) |
Declare a many-to-many relationship through a join table |
Defining Relationships
Add relationship declarations at the top of your model class body:
class User < Model
has_many("posts")
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 allows a model to belong to more than one other model on a single association.
The related type and ID are stored in commentable_type and commentable_id fields:
class Comment < Model
belongs_to("commentable", { "polymorphic": true })
end
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
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, .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
# 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_allis a no-op, 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
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
Manual Relationships
For more control, implement relationships as custom instance methods:
class Post < Model
def author
User.find(this.author_id)
end
end