ESC
Type to search...
S
Soli Docs

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 — null when 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), and reset_counters recounts with the guard. dependent: works on as: 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). Access record.commentable directly. The as: 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 — count is 0, delete_all / update_all are no-ops, iteration yields nothing.
  • If the related model uses soft_delete, soft-deleted children are excluded from the relation. Use the static Related.with_deleted / Related.only_deleted to query them explicitly.
  • belongs_to and has_one still return a single instance (or nil), 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_delete runs first (a false veto aborts the cascades too), then each dependent: relation in declaration order, then the owner row is removed, then after_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, so restore() has nothing to un-do.
  • Model.delete(id) cascades too on classes that declare dependent: (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.transaction for 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 with source:. Both belongs_to sources (join model) and has_many sources (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). Only belongs_to sources are writable; has_many-source pushes and unpersisted owners raise.
  • Bulk writes and eager-loading stay off. delete_all/update_all raise (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. true derives 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.

Next Steps