ESC
Type to search...
S
Soli Docs

Models & ORM

Models manage data and business logic in your MVC application. SoliLang provides a simple OOP-style interface for database operations.

Defining Models

Create model files in app/models/. The collection name is automatically derived from the class name:

  • User"users"
  • BlogPost"blog_posts"
  • UserProfile"user_profiles"

Automatic Collection Creation: When you call a Model method (like create(), all(), find(), etc.) on a collection that doesn't exist yet, SoliLang will automatically create the collection for you. If the configured database doesn't exist yet either, it is created on that same first call before the collection. This means you can start using your models immediately without running migrations first.

class User < Model end

That's it! No need to manually specify collection names or field definitions.

Single-Collection Inheritance (STI)

A model inheriting from another model shares its base's collection with a type discriminator — Rails single-table inheritance, per-collection:

class User < Model
  validates("email", {"presence": true})
  has_many "posts"
end

class Admin < User
  def badge
    "admin"
  end
end

Admin.create({"email": "a@x.co"})   # stored in `users` with type: "Admin"
User.all()                           # every row — admins hydrate AS Admin
Admin.all()                          # only Admin rows (and Admin's descendants)
User.find(admin._key).badge()        # "admin" — hydration follows the type
  • Subclass writes stamp type; rows hydrate as their stored type everywhere (find, where, relation accessors). A guard ignores type values that don't name a model class of the row's collection.
  • Subclass queries are type-scoped (including descendants); the base class matches every row, Rails-style. Admin.find(user_key) on a base row raises RecordNotFound; class-form update(id)/delete(id) refuse rows outside the hierarchy; Admin.delete_all only removes its own rows.
  • Metadata copies down at class definition: validations, callbacks, relations (with the base's foreign key), scopes, soft-delete, attr_accessible, encrypts, enums, and state machines. Declare the parent before its subclasses; parent DSL added after a subclass is defined does not propagate.
  • The discriminator column is always type — avoid a user field of that name on STI hierarchies.

Auto-Loading

Every .sl file under app/models/ is loaded automatically at startup — by soli serve (in each worker) and by the REPL. Model classes are therefore available everywhere (controllers, views, other models, the REPL) without an import statement.

class UsersController < Controller
  def index
    render("users/index", { "users": User.all })
  end
end

Loading is recursive — subdirectories are walked too, so app/models/billing/invoice.sl is auto-loaded just like a top-level file. The same recursive auto-loader also covers two sibling directories, loaded before controllers so they can be referenced freely:

  • app/services/ — integration / domain-service classes (Stripe, mailers, etc.)
  • app/policies/ — authorization policies (see Authorization)

Within a directory, files load in alphabetical order and before their subdirectories (top-down). Soli executes files eagerly, so if one class extends another defined in a different file, keep the base class at an equal-or-shallower depth (e.g. app/models/application_record.sl) so it loads first. In --dev, edits to nested files hot-reload without a restart.

If you run a file directly with soli run path/to/file.sl, the auto-loader does not run — in that case you still need explicit imports. The linter flags redundant controller-side imports via style/redundant-model-import.

CRUD Operations

Auto-creation: All Model operations automatically create the collection — and the database itself, if it's missing too — when it doesn't exist. This only happens on the first call that encounters a missing collection or database.

Class Instances: All query methods (find, all, where, create, etc.) return proper class instances. For example, User.find(id) returns a User instance, not a raw hash. This enables instance methods like .save(), .update(), and .delete().

CREATE Creating Records

user = User.create({
  "email": "alice@example.com",
  "name": "Alice",
  "age": 30
})
# Returns a User instance. On success, user._errors is nil.
# On validation or DB failure the instance is NOT persisted and
# user._errors is an array of error entries.

user.name    # => "Alice"
user._key    # => "abc123" (auto-generated)
user._errors # => nil

READ Finding Records

# Find by ID - returns a User instance
user = User.find("user123")
user.name  # => "Alice"

# Find all - returns array of User instances
users = User.all
users.first.name  # => "Alice"

# Find with filter — Hash form (recommended for user input).
# Each key is validated as an AQL identifier and values flow through
# bind parameters; equality semantics, all pairs joined with AND.
admins = User.where({ "role": "admin", "active": true }).all
alice  = User.where({ "email": "alice@example.com" }).first

# Find with filter — string form (developer-trusted only). Use this when
# you need operators (>=, IN, etc.). The filter string MUST NOT come
# from untrusted input — see the Security note below.
adults = User.where("age >= @age", { "age": 18 }).all
results = User.where("age >= @min AND role == @role", {
  "min": 21,
  "role": "admin"
}).all

# Dynamic finder methods — automatically generated
user = User.find_by_email("alice@example.com")
user = User.find_by_email_and_active("alice@example.com", true)

Security — where(...) filter forms. The Hash form (where({field: value, ...})) is safe for user input: keys are validated as [A-Za-z_][A-Za-z0-9_]* identifiers and values are bound, so nothing from req["params"] can become AQL syntax. The string form (where("doc.foo == @foo", {...})) splices the filter argument verbatim into the AQL FILTER clause — treat it as developer-trusted only, like a format!() template. Building a filter string from request data will leak full AQL injection. When the operators you need go beyond equality, prefer composing small string-form clauses around literal strings rather than concatenating user input into them.

UPDATE Updating Records

# Static method: update by ID
User.update("user123", { "name": "Alice Smith", "age": 31 })

# Instance method: modify fields and save
user = User.find("user123")
user.name = "Alice Smith"
user.save()

# Or use .update() to push current fields to DB
user.name = "Alice Smith"
user.update()

# Instance method with bulk-update hash — merge-then-persist in one call
user.save({ "name": "Alice Smith", "age": 31 })

# `.update(hash)` is equivalent on an existing record
user.update({ "name": "Alice Smith", "age": 31 })

DELETE Deleting Records

# Static method
User.delete("user123")

# Instance method
user = User.find("user123")
user.delete()

COUNT Counting Records

total = User.count

Instance Methods

All model instances have built-in methods for persistence:

Method Description
instance.save(hash?) Insert (if new) or update (if existing). Optional hash is merged onto the instance before validation & persist. Returns the instance with _key populated.
instance.update(hash?) Persist current non-_ fields to DB. Optional hash is merged first. Requires _key.
instance.delete() Delete the document from DB. Requires _key. Supports soft delete.
instance.reload() Refresh instance from database.
instance.increment(field, n?) Atomically add n (default 1) to a numeric field. Uses an If-Match CAS loop on _rev with bounded retry — concurrent increments cannot lose updates.
instance.decrement(field, n?) Atomically subtract n (default 1) from a numeric field. Same CAS-on-_rev retry as increment.
instance.touch() Update _updated_at timestamp without changing other fields.
instance.restore() Restore a soft-deleted record (clear deleted_at).
instance.errors Return validation errors from last save/update.
instance.changed? True when any attribute differs from the loaded/persisted baseline. New records report every attribute as changed.
instance.changed Sorted array of changed attribute names.
instance.changes Hash of unsaved changes: { "name": [old, new] }.
instance.previous_changes What the last successful save/update/create persisted, same shape as changes. Failed persists leave it untouched.
instance.attribute_was(name) Baseline value of one attribute (null on a new record). No dynamic name_was methods.

Dirty Tracking

The baseline resets on load (find/where), successful persist, and reload(); atomic increment/decrement and soft delete()/restore() keep their written field clean. Tracking is value-based: reassigning an equal value is not a change, and mutating a nested Hash/Array in place is invisible — reassign the attribute to record it.

user = User.find(id)
user.name = "New Name"

user.changed?                 # true
user.changed                  # ["name"]
user.changes                  # { "name": ["Old Name", "New Name"] }
user.attribute_was("name")    # "Old Name"

user.save()
user.changed?                 # false
user.previous_changes         # { "name": ["Old Name", "New Name"] }
# Create a new instance and save it
user = User.new()
user.name = "Bob"
user.email = "bob@example.com"
user.save()
# user._key is now set

# Modify and save again
user.name = "Robert"
user.save()

# Delete
user.delete()

Atomic increment / decrement

increment and decrement are not plain read-modify-writes on the in-memory instance — each call drives an optimistic compare-and-swap loop against SoliDB:

  1. Re-fetch the document to read the current field value and its _rev.
  2. Compute current + delta (or current - delta).
  3. PUT the new value with an If-Match: <rev> header.
  4. If another writer modified the document in between, the DB returns 409 Conflict and the loop retries (up to 10 attempts) by re-fetching.

On success the in-memory instance's field and _rev are refreshed, so any follow-up call observes the same state the DB now holds. Concurrent increments cannot lose updates: every successful PUT was the unique continuation of the rev it read.

Under extreme contention all 10 retries can fail; the call returns an error like "increment failed: Atomic update of users.view_count failed after 10 attempts (too much contention)" instead of silently dropping the update. Callers can retry, queue the work, or back off as they prefer.

Bulk attribute updates

Both .save() and .update() accept an optional hash of attributes that get merged onto the instance before validations and the DB write run. One call replaces N assignments + save:

# Instead of:
user.name = "Alice"
user.email = "alice@example.com"
user.role = "admin"
user.save()

# Write:
user.save({
  "name":  "Alice",
  "email": "alice@example.com",
  "role":  "admin"
})

Merge semantics: keys you don't pass keep their current instance value; keys you do pass overwrite. Mix it with direct assignment too — hash wins on conflict:

# Partial update
p = Product.find(id)
p.update({ "price": 99.00 })    # only price changes

# Mix pre-assignment + hash
p = Product.new()
p.name = "Widget"               # will survive
p.save({ "price": 12.50 })      # name stays "Widget", price becomes 12.50

Read-only fields: Fields starting with _ (_key, _id, _rev, etc.) are read-only on model instances. They're set automatically by the database and cannot be assigned directly — and are silently skipped when included in a bulk-update hash.

A non-hash argument raises expected a Hash of attributes, got <type>. Validations run after the merge, so errors surface on instance.errors identically to the assignment-then-save pattern.

Static Methods Reference

Method Description
Model.create(data) Insert a new document. Returns a class instance; on failure instance._errors is an array and the record is not persisted, on success instance._errors is nil.
Model.create_many([data, ...]) Batch insert multiple documents, returns { "created": n }
Model.find(id) Raises RecordNotFound when the id is missing (auto-mapped to a 404 HTTP response). Use find_by for optional lookups.
Model.find_by(field, value) Find first record by field value, returns null when missing.
Model.first_by(field, value) Find first record by field with ordering
Model.find_or_create_by(field, value, data?) Find by field, or create if not found
Model.find_by_* Dynamic finder: find_by_field, find_by_field1_and_field2(...), etc.
Model.where(hash) Hash filter — safe for user input (keys validated, values bound)
Model.where(string, bind_vars) SDBQL filter string — developer-trusted only (do not pass user input)
Model.order(field, direction) Start a query chain sorted by field ("asc"/"desc")
Model.limit(n) Start a query chain limited to n results
Model.offset(n) Start a query chain with offset
Model.paginate(hash) Terminal: fetch paginated results + metadata. Args: page (default 1), per (default 25)
Model.all Get all documents as instances
Model.count Count all documents
Model.update(id, data) Update a document
Model.upsert(id, data) Insert or update document by ID
Model.delete(id) Delete a document
Model.delete_all Wipe every document in the collection (primarily for test setup/teardown). For filtered bulk deletes use Model.where(...).delete_all.
Model.transaction() Get transaction handle for manual control
Model.scope(name) Execute a named scope (returns QueryBuilder)
Model.with_deleted Include soft-deleted records (QueryBuilder)
Model.only_deleted Query only deleted records (QueryBuilder)
Model.includes(rel, ...) Eager load relations (returns QueryBuilder)
Model.select(field, ...) Select specific fields (returns QueryBuilder)
Model.join(rel, filter?, binds?) Filter by related existence (returns QueryBuilder)
Model.sum(field) / avg / min / max Aggregation (returns QueryBuilder, chain .first to execute)
Model.group_by(field, func, agg_field) Legacy group-by aggregation — returns [{group, result}] (returns QueryBuilder, chain .all)
Model.pluck(field, ...) Select fields only (returns QueryBuilder, chain .all)
Model.time_bucket(interval, aggs?) Timeseries models only: bucketed aggregation (returns QueryBuilder, chain .all)
Model.prune(cutoff?) Timeseries models only: delete rows older than a duration ("30d") or RFC3339 cutoff; without an argument uses the declared retention:. Returns the number deleted.
Model.group_by(fields) Grouped aggregation — field name or array; without aggregate yields a count per group (n). Returns QueryBuilder, chain .all. See Analytics.
Model.aggregate(spec) Multi-aggregate spec { alias: [func, field] } (or ["count"]); with group_by one row per group, without it one row (chain .first)
Model.median(field) / stddev / variance / count_distinct Statistical aggregation terminals (returns QueryBuilder, chain .first)
Model.similar(query, field?, k?, opts?) Vector similarity search; with a declared vector_index pushes down to the HNSW ANN index. opts: { "exact": true }. See Search.
Model.search(query, opts?) Fulltext search (requires fulltext_index); eager, ranked, results carry _search_score. See Search.
Model.near(lat, lon, opts?) / Model.within(lat, lon, radius) Geo search (requires geo_index); eager. near sorts by distance and adds _distance (meters); within takes a radius in meters.
Model.insert_rows(rows) Columnar models only: batch append, returns { "inserted": n, "ids": [...] }
Model.aggregate(field, op, opts?) Columnar models only: scalar aggregate, or per-group rows with { "group_by": [...] }
Model.query(spec) Columnar models only: fetch rows (columns / one filter / limit)
Model.add_column_index(field, kind?) / column_indexes / drop_column_index(field) Columnar models only: manage column indexes (sorted default, hash, bitmap, minmax, bloom)
Model.columnar_stats Columnar models only: store-level statistics
Model.mock_query_result(query, results) Register mock data for an AQL query (for testing)
Model.clear_mocks() Clear all registered mock responses

Storage & Index DSL

Class-body declarations for the multi-model and search features. Index declarations are metadata-only at load: dev ensures them at server boot; in production run soli db:indexes or create them in migrations. See Search — sync strategy.

Declaration Description
columnar options? Declare a columnar-store model (compression: "lz4" default, or "none"). See Analytics & Columnar Stores.
column name, type, options? Declare a typed column on a columnar model (nullable:, indexed:)
vector_index field, dimension:, metric: Declare an HNSW vector index (optional m:, ef_construction:, quantization:, name:). See Search.
fulltext_index field, ... Declare a fulltext index over one or more fields
geo_index field Declare a geospatial index ({ "lat": ..., "lon": ... } field)
index field_or_fields, options? Declare a secondary index (unique:, type:"persistent" default / "hash" / "fulltext" / "bloom" / "cuckoo", name:)

Mass Assignment Protection

By default, Model.create(hash) and instance.update(hash) write every key in the supplied hash straight to the document. If hash came from a request body, that includes any field a client decides to send — role, is_admin, password_digest, etc. Declare attr_accessible(...) on the model to lock down which keys mass-assign accepts.

class User < Model
  # Variadic form
  attr_accessible("name", "email", "bio")

  # …or a single array — equivalent
  # attr_accessible(["name", "email", "bio"])
end

User.create({
  "name":  "Alice",
  "email": "alice@example.com",
  "role":  "admin"   # silently dropped — not in the whitelist
})

Filtering applies to every mass-assign path: Model.create(hash), Model.update(id, hash), instance.update(hash), instance.save(hash). Non-permitted keys are dropped before validation runs and before the document is written, so they cannot be probed via validation errors either.

Empty list = full lock-down. attr_accessible([]) declares that the model accepts no mass-assigned attributes; everything must be set by trusted server code via direct field assignment (e.g. user.role = "admin").

Models without a declaration keep the legacy "all keys accepted" behaviour for backwards compatibility. New models that take request data should always declare attr_accessible; audit every Model.create/Model.update call site against an explicit whitelist.

How this relates to permit(): permit(params, shape) is the primary mass-assignment filter — it lives in the controller, where assignability is actually decided, and it understands nested shapes. attr_accessible is optional defense-in-depth for high-stakes models and covers paths that bypass controllers (jobs, websocket handlers, a raw Model.create(params)). Both are whitelists, so the result is their intersection: if you use both, attr_accessible must list every top-level key any controller permits, or the value is silently dropped at the model layer — in --dev such drops log a [WARN] attr_accessible on … dropped mass-assign key(s). attr_accessible is flat: for nested assignable fields, list the top-level key and let permit control the sub-shape.

For controller-side filtering (when you'd rather hand-pick keys at the boundary), hash.slice(["a", "b"]) returns a new hash with only the listed keys — useful when you need a different whitelist per action:

def update
  let user = User.find(req["params"]["id"])
  let safe = req["json"].slice(["name", "bio"])
  user.update(safe)
  redirect("/users/" + user._key)
end

Pagination

Model.paginate(hash) (static) and .paginate(hash) (chainable on a QueryBuilder) are terminal methods that execute the query with pagination and return a hash with both records and pagination metadata.

Arguments

Key Default Description
page 1 Page number (1-indexed, clamped to valid range)
per 25 Results per page

Return Value

{
  "records": [...],                # Array of model instances for this page
  "pagination": {
    "page":        1,              # Current page (clamped)
    "per":         25,             # Results per page
    "total":       100,            # Total matching records (unpaginated)
    "total_pages": 4               # Total number of pages
  }
}

Usage

Chain from any QueryBuilder — all filters, includes, ordering, etc. are preserved:

def index
  let result = Contact
    .search(@q)
    .includes("organisation")
    .order("name", "asc")
    .paginate({ "page": page, "per": 25 })

  @contacts   = result["records"]
  @pagination = result["pagination"]
end

The paginate method runs count first to get the total, computes total_pages, clamps page to the valid range, sets offset and limit, fetches records, and returns the result hash. If total is 0, total_pages is set to 1 and page is clamped to 1.

Uploaders

Declare a blob attachment on a model with uploader(name, options). Soli registers the field, validates incoming files against the rules you supply, and stores the blob in SoliDB. The DSL also auto-generates instance methods so the controller is a one-liner.

class Contact < Model
  uploader("photo", {
    "multiple":      false,
    "content_types": ["image/jpeg", "image/png", "image/webp"],
    "max_size":      2_000_000,
    "collection":    "contact_photos"   # optional, defaults to <snake>_<field>s
  })
end

Options

  • multiplefalse stores one blob in <name>_blob_id; true stores an array of ids in <name>_blob_ids.
  • content_types — allowlist checked before the blob is stored. Anything else fails fast with no SoliDB round-trip.
  • max_size — hard cap in bytes. Same fast-fail.
  • collection — SoliDB collection name. Optional; defaults to <model_snake_case>_<field>s.
  • format — convert image uploads to "jpeg", "png", or "webp" before storage. Non-image uploads (PDF, csv, …) are never converted.
  • quality — encoder quality (1–100) for lossy formats (jpeg, webp). Defaults to 82.
  • max_width / max_height — downscale the original to fit within these pixel bounds before storage, preserving aspect ratio. Never upscales.

Transform the original before storage

To avoid storing heavy originals (e.g. large PNG photos), declare a format and/or size caps. Soli decodes the upload, optionally downscales it, re-encodes it to the target format, and stores the result — updating the blob's content-type and filename extension to match. WebP is encoded lossy (via libwebp) so photos shrink dramatically; JPEG quality is honoured too.

class Listing < Model
  uploader("photo", {
    "content_types": ["image/jpeg", "image/png", "image/webp"],
    "max_size":      10_000_000,   # accept up to 10 MB on the way in
    "format":        "webp",       # …but store a lossy WebP
    "quality":       80,
    "max_width":     1600,         # downscale huge originals
    "max_height":    1600
  })
end

A 4 MB PNG uploaded here lands in storage as a downscaled ~200 KB WebP. The transform only runs for image content-types; if the bytes can't be decoded as an image the original is stored unchanged, so an upload is never blocked by a transform failure. The same lossy WebP/quality encoding also powers the read-time photo_url(...) transform pipeline.

Auto-generated instance methods

For each declared uploader, Soli synthesizes:

Method Behavior
contact.attach_photo(file)Validate, store, replace the previous blob (single mode) or append (multiple mode). Sets _errors on failure.
contact.detach_photo([blob_id])Remove the current blob (single) or the named one (multiple). blob_id is required for multiple uploaders.
contact.photo_url()Return the proxy URL or null. Single-mode only.
contact.photo_urls()Return one URL per stored blob_id. Multiple-mode only.

A typical edit flow becomes a single call:

def update
  @contact = Contact.find(params.id)
  if @contact.update(this._permit(params))
    return @contact.detach_photo() if params["remove_photo"] == "1"
    file = find_uploaded_file(req, "photo")
    @contact.attach_photo(file) unless file.nil?
    # ...
  end
end

Multiple attachments

Set multiple: true to keep an array of blob ids on the record. The auto-generated attach_<field>(file) appends; detach_<field>(blob_id) removes a specific blob. detach_all_uploads(record) walks every uploader on destroy.

class Contact < Model
  uploader("document", {
    "multiple":      true,
    "content_types": ["application/pdf", "image/jpeg", "image/png",
             "application/zip", "text/csv"],
    "max_size":      10_000_000,
    "collection":    "contact_documents"
  })
end

For HTML form upload (POST → redirect → flash → reload page), thin the controller to one call per side; _errors carries the framework's validation message:

# POST /contacts/:id/documents
def attach_document
  contact = Contact.find(params.id)
  file = find_uploaded_file(req, "document")
  if file.nil?
    flash("error", "Pick a file before submitting.")
  elsif contact.attach_document(file)
    flash("success", "Document filed.")
  else
    flash("error", (contact._errors[0] ?? { "message": "Upload failed." })["message"])
  end
  redirect("/contacts/#{contact._key}")
end

# POST /contacts/:id/document/:blob_id/delete
def detach_document
  contact = Contact.find(params.id)
  if contact.detach_document(params.blob_id)
    flash("success", "Document removed.")
  else
    flash("error", "Document not found on this record.")
  end
  redirect("/contacts/#{contact._key}")
end

Prefer uploads("contacts", "document") in routes if you want JSON 204/422 (drag-and-drop with JS); use the manual routes above when an HTML form needs to redirect on success.

Shipped with the framework — no setup needed

The upload helpers (find_uploaded_file, attach_upload, detach_upload, detach_all_uploads, upload_url) and the generic AttachmentsController that handles auto-routed attachments are loaded on every soli serve — you do not need to copy any controller file into app/controllers/. Declaring uploader(...) on a model and uploads("resource", "field") in routes is enough.

SoliDB connection details come from environment variables (with sensible defaults):

  • SOLIDB_HOST — default http://localhost:6745
  • SOLIDB_DATABASE — default default
  • SOLIDB_USERNAME / SOLIDB_PASSWORD — auth skipped if unset

Image transforms via URL

For image attachments, the auto-routed GET /<resource>/:id/<field> endpoint can resize, crop, recompress, and reformat on the fly. Pass options to upload_url(...) (or the auto-generated <field>_url(...) dot-method) and they're rendered into the URL's query string. The AttachmentsController reads the same params from req["query"], runs the bytes through the Image class, and returns the transformed payload.

Param Type Effect
wInt (px, ≤1000)Width. Alone: thumbnail to that max edge (preserves aspect). With h: behaviour depends on fit. Values above 1000 are silently clamped.
hInt (px, ≤1000)Height. Pair with w. Clamped to 1000.
thumbInt (px, ≤1000)Square-fit thumbnail at this max edge. Output is at most N×N preserving aspect — a 800×400 source becomes 200×100 with thumb=200. Wins over w/h if both supplied. Clamped to 1000.
squareInt (px, ≤1000)Sugar for w=N&h=N&fit=cover. Output is exactly N×N, scaled-and-cropped to fill — typical avatar/grid use case. Explicit w/h/fit override the shorthand. Clamped to 1000.
cropString x,y,w,hCrop a rectangular region from the source before any resize. e.g. crop=10,20,300,200. Accepts an Array [10,20,300,200] in the options hash. The w and h components are clamped to 1000; x/y offsets are unbounded (out-of-bounds is rejected by the Image lib and falls back to the raw blob).
fitStringPairs with w + h: cover = scale to fill then center-crop overflow (output is exactly w×h); contain = scale to fit inside w×h, preserving aspect. Without fit, w + h is an exact resize that may distort.
flipxTruthy flagMirror horizontally (img.flip_horizontal()).
flipyTruthy flagMirror vertically (img.flip_vertical()).
rotInt 90/180/270Rotate clockwise. Other values are ignored.
blurFloatGaussian blur sigma — higher = more blur. blur=3.5.
brightInt (±)Brightness offset (img.brightness(value)). Positive brightens, negative darkens.
contrastFloatContrast factor (img.contrast(value)).
hueInt degreesHue rotation (img.hue_rotate(degrees)).
grayTruthy flagConvert to grayscale (img.grayscale()).
invertTruthy flagInvert all colors (img.invert).
fmtStringOutput format: jpeg, png, webp, gif, bmp, tiff, ico. Sets the response Content-Type.
qInt 1–100JPEG/WebP quality.

All flags are optional. Without any, the original bytes are streamed unmodified (current behaviour).

DoS guardrail. The dimension params (w, h, thumb, square, and the w/h in crop) are server-side clamped to 1000 px. A crafted ?w=99999 won't make the worker allocate gigabytes — it's silently treated as ?w=1000. Cap is enforced in the framework prelude regardless of what the URL builder generates.

Sizing modes — when to use which. Given a 800×400 source:

  • w=200 alone or thumb=200200×100 (fits within 200×200, aspect preserved, smaller dim shrinks too).
  • w=200&h=200200×200 stretched (squashed, aspect distorted — usually not what you want).
  • w=200&h=200&fit=cover or square=200200×200 exact (scale-and-crop, content cropped left/right, aspect preserved within the crop).
  • w=200&h=200&fit=contain200×100 (same as thumb=200 here; useful when you want a non-square bounding box).

Reach for square=N for avatars and grid tiles where every cell must be the same size. Use thumb=N (or w=N) when you need to bound the larger edge but keep aspect.

<img src="<%= contact.photo_url({ "thumb": 200 }) %>" alt="...">
# →  <img src="/contacts/42/photo?v=abc&thumb=200" alt="...">
contact.photo_url({
  "w":    800,
  "h":    600,
  "fmt":  "webp",
  "q":    80,
  "gray": true
})
# →  /contacts/42/photo?v=abc&w=800&h=600&fmt=webp&q=80&gray=1
contact.photo_url({ "square": 200 })
# →  /contacts/42/photo?v=abc&square=200

contact.photo_url({ "w": 200, "h": 200, "fit": "cover" })
# →  /contacts/42/photo?v=abc&w=200&h=200&fit=cover

# Both render exactly 200×200, content scaled-and-cropped.
contact.photo_url({ "crop": [100, 50, 600, 600], "w": 200 })
# →  /contacts/42/photo?v=abc&w=200&crop=100%2C50%2C600%2C600
# (the comma is percent-encoded; the AttachmentsController decodes it.)
contact.photo_url({
  "rot":      90,
  "blur":     2.5,
  "bright":   15,
  "contrast": 1.2
})
# →  /contacts/42/photo?v=abc&rot=90&blur=2.5&bright=15&contrast=1.2

Pipeline order. Transforms are applied in a fixed sequence so identical params always produce identical output (and identical URLs — cache-key stability for CDNs):

  1. crop — pick the source region first.
  2. flipx, flipy, rot — orientation.
  3. thumb / fit / w+h — sizing applies to the rotated frame.
  4. blur, bright, contrast, hue, gray, invert — effects.
  5. fmt, q — encode.

Caching. Each unique combination of params is a different URL — browsers and CDNs cache them as separate entries. The cache buster (?v=<blob_id>) keeps single-mode URLs unique per blob, so replacing the photo invalidates every transformed variant at once. Transformed responses are served with Cache-Control: public, max-age=86400; the raw passthrough uses private, max-age=300.

If a transform fails for any reason (corrupted bytes, unsupported source format, invalid params), the controller silently falls back to streaming the original blob — the page never breaks because of a bad query string.

Overriding the defaults

Define a same-named function or class in app/controllers/ and your version wins — the framework prelude runs before user controllers, so user definitions naturally shadow it.

class AttachmentsController < ApplicationController
  static {
    this.before_action(:create, :destroy, fn(req) {
      return halt(403) unless req["current_user"].is_admin()
    })
  }
  # ... or override individual actions
end

Same trick for solidb_client(): define your own in app/controllers/support.sl and the prelude's default is bypassed.

Cleanup on destroy

before_delete callbacks aren't yet dispatched by Model.delete(id), so call detach_all_uploads(record) explicitly before deleting until that lands. The helper iterates every uploader declared on the class.

def destroy
  contact = Contact.find(params.id)
  detach_all_uploads(contact) unless contact.nil?
  Contact.delete(params.id)
  redirect("/contacts")
end

Reflection helpers

Inside controllers or generic helpers you can read a model's uploader configuration without hardcoding the rules:

  • model_uploader_config(class_or_name, field) → hash of { name, multiple, content_types, max_size, collection }, or null.
  • model_uploader_fields(class_or_name) → list of declared field names.
  • find_model_class_by_collection(collection) → the model class whose class_name_to_collection matches, or null.

See Auto-routed attachments for the matching uploads(resource, field) route helper, which mounts show/create/destroy endpoints driven by the same config.

Encrypted Attributes

Encrypt sensitive fields at rest with encrypts. Values are encrypted on create/save/update and decrypted transparently on load, using AES-256-GCM. The key comes from the SOLI_ENCRYPTION_KEY environment variable (use a long, high-entropy secret such as Crypto.random_hex(32)).

class User < Model
  encrypts(:ssn, :api_token)
end

u = User.create({ "ssn": "123-45-6789", "email": "a@b.com" })
User.find(u._key).ssn   # => "123-45-6789" (decrypted)

Encrypted columns can't be queried by value. AES-GCM uses a random nonce, so the same plaintext encrypts differently each time — where("ssn = @s", ...) will never match. Encrypt only fields you store and read, not ones you filter on. Legacy plaintext written before encrypts was added is returned as-is; low-level/transaction writes that bypass create/save aren't auto-encrypted (use Crypto.encrypt there). The same Crypto.encrypt / Crypto.decrypt builtins are available standalone.

Graph Models (Edges & Traversal)

SoliDB is multi-model: alongside document collections it supports native edge collections. Declare an edge model with the edge DSL and the Model API gains graph creation, traversal, and shortest-path queries:

class Follow < Model
  edge from: "users", to: "users"
end

from: and to: accept collection names ("users") or model class names ("User"). The declaration marks the collection as the SolidB edge type — in dev it is auto-created with that type plus hash indexes on _from and _to.

Creating edges

Endpoints can be model instances, full "collection/key" ids, or bare keys:

f = Follow.create(from: alice, to: bob)
f = Follow.create({ "from": alice, "to": "users/bob-key", "since": 2024 })

f._from   # => "users/alice-key"
f._to     # => "users/bob-key"

Edges are ordinary model records — validations, callbacks, and extra attributes (since above) work as usual. A missing or invalid endpoint behaves like a failed validation: f._errors is set (e.g. [{ "field": "from", "message": "..." }]) and the edge is not persisted.

Traversal

Call .traverse(EdgeModel, options?) on a saved record to get a chainable QueryBuilder over the reachable vertices. direction defaults to "out" (also "in" / "any"); depth defaults to 1 — an Int n traverses depth 1..n, a [min, max] array sets an explicit range:

friends   = alice.traverse(Follow).all                    # OUTBOUND, depth 1..1
followers = alice.traverse(Follow, direction: "in").all   # INBOUND
network   = alice.traverse(Follow, depth: 3).all          # depth 1..3

# Full chain — vertex filters use the usual `doc` variable; prefix a filter
# with `edge.` to match edge attributes instead:
ring = alice.traverse(Follow, depth: [2, 3], direction: "any")
  .where({ "active": true })
  .where("edge.since >= @y", { "y": 2024 })
  .order("name")
  .limit(10)
  .all

n = alice.traverse(Follow).count

Depth gotcha: Soli's 1..3 range literal materializes to the exclusive-end array [1, 2]. Prefer an explicit [1, 3] array for depth ranges.

  • Results are instances of the target model — the class is resolved per document, so mixed-vertex graphs return the right class for each vertex.
  • Soft-delete models compose: the deleted-filter is applied to the vertices.
  • A traversal builder does not compose with includes, includes_count, join, group_by, similar, update_all, or delete_all — combining them raises a clear error.
  • traverse and shortest_path require a saved record (they raise otherwise).

Shortest path

shortest_path executes immediately (no .all) and returns the vertices along the shortest path between two records — [] when they are not connected. Direction defaults to "any":

path = alice.shortest_path(bob, via: Follow)
path = alice.shortest_path("users/bob-key", via: Follow, direction: "out")

Relation sugar

An idiomatic named relation over a traversal is just a plain method:

class User < Model
  def followers()
    return this.traverse(Follow, { "direction": "in" })
  end
end

User.find(key).followers().where({ "active": true }).count

For explicit production migrations, create the collection with the "edge" type and add hash indexes on _from/_to — see Migrations.

Timeseries Models

Declare a model as timeseries with the timeseries DSL. The collection is created with the SolidB timeseries type — optimized for append-only, time-indexed data (metrics, logs, telemetry):

class Metric < Model
  timeseries retention: "30d"     # bare `timeseries` (no options) also works
end

class Reading < Model
  timeseries retention: "90d", timestamp: "recorded_at"
end
  • retention: — optional retention window; used by prune when called without an argument.
  • timestamp: — the field time_bucket buckets on. Defaults to the server-set _created_at.

Insert-only

Timeseries records are insert-only, mirroring the database's own rules. Documents get UUIDv7 keys, so _key order is insertion-time order:

Metric.create({ "device": "srv1", "value": 0.42 })

m.update({ "value": 1 })
# => raises: "Metric is a timeseries model: records are insert-only.
#    update is not supported — use prune() for retention."

Blocked: static update / upsert, instance update / save on an existing record / increment / decrement, and update_all. Allowed: delete and delete_all.

Don't set _key yourself on a timeseries model. prune relies on the UUIDv7 key ordering — documents inserted with a caller-supplied _key are never pruned.

Bucketed aggregation (time_bucket)

time_bucket(interval, aggregates?) groups rows into fixed time buckets and computes per-bucket aggregates. Chain it after where(...) filters and finish with .all:

rows = Metric.where("device = @d", { "d": "srv1" })
  .time_bucket("5m", { "avg": "value", "max": "value" })
  .all
# => [{ "bucket": "2026-07-05T10:00:00+00:00", "avg": 0.41, "max": 0.9 }, ...]

rows = Metric.time_bucket("1d").all                  # bare form: count per bucket
# => [{ "bucket": "...", "count": 42 }, ...]

rows = Metric.time_bucket("1h", avg: "value").all    # keyword style works too
  • Interval units: s, m, h, d (the database's TIME_BUCKET contract).
  • Aggregates: sum / avg / min / max take a field name; count takes true ({ "count": true }). With no aggregates you get a count per bucket.
  • Buckets fall on the declared timestamp: field (default _created_at).
  • Rows come back as plain hashes — bucket (an RFC3339 string) plus your aggregate aliases — sorted by bucket.

Retention (prune)

prune deletes rows older than a cutoff and returns the number deleted:

deleted = Metric.prune("30d")                    # duration cutoff (units: s/m/h/d/w)
deleted = Metric.prune("2026-06-01T00:00:00Z")   # explicit RFC3339 cutoff
deleted = Metric.prune                           # uses the declared retention:

There is no auto-prune scheduler. Register a recurring cron job whose handler calls prune:

Cron.schedule("prune_metrics", Cron.daily_at("03:00"), "PruneMetricsJob", {})
# ...and PruneMetricsJob.perform is a one-liner: Metric.prune

Dev auto-create provisions the timeseries collection type for you; the explicit migration form is db.create_collection("metrics", "timeseries"), and db.prune_collection(name, cutoff) handles one-off cleanups. For continuous aggregates (downsampling on write), SolidB streams are the raw-SDBQL escape hatch — db.query("CREATE STREAM ...") in a migration. See Migrations.

Complete Example

# app/models/user.sl
class User < Model
  has_many("posts")      # strings and symbols both work
  has_many(:posts)       # Ruby-style symbol shorthand
  has_one("profile")

  validates("email", { "presence": true, "uniqueness": true })
  validates("name", { "presence": true, "min_length": 2 })

  before_save("normalize_email")   # strings and symbols both work
  before_save(:normalize_email)    # Ruby-style symbol shorthand

  def normalize_email
    this.email = this.email.downcase
  end

  def is_adult -> Bool
    this.age >= 18
  end
end

# app/models/post.sl
class Post < Model
  belongs_to("user")
  has_many("comments")

  validates("title", { "presence": true, "min_length": 3 })
end

# Usage in controller
class UsersController < Controller
  def index(req: Any)
    # Eager load posts and profiles to avoid N+1 queries
    users = User.includes("posts", "profile").all
    render("users/index", { "users": users })
  end

  def show(req: Any)
    id = req["params"]["id"]
    user = User.includes("posts").find(id)
    render("users/show", { "user": user })
  end

  def active(req: Any)
    # Find active users who have at least one post
    users = User.join("posts")
      .where("active = @a", { "a": true })
      .order("created_at", "desc")
      .limit(10)
      .all
    render("users/active", { "users": users })
  end

  def create(req: Any)
    user = User.create({
      "name": req["params"]["name"],
      "email": req["params"]["email"]
    })
    if user._errors
      render("users/new", { "errors": user._errors })
    else
      return redirect("/users/" + user._key)
    end
  end

  def update(req: Any)
    user = User.find(req["params"]["id"])
    user.name = req["params"]["name"]
    user.save()
    redirect("/users/" + user._key)
  end

  def destroy(req: Any)
    user = User.find(req["params"]["id"])
    user.delete()
    redirect("/users")
  end
end

Inspecting AQL Queries (Dev Tool)

When the server runs with --dev, every AQL query a request executes through the Model layer is captured into a per-request stack. Read it from any controller or view with the dev_queries() builtin to render a debug bar.

def index
  users = User.where("doc.active == true").all
  render("users/index", { "users": users, "queries": dev_queries() })
end

# dev_queries() returns Array<Hash> with keys: query, bind_vars, duration_ms.
# In production it always returns [] with zero overhead.

See the AQL Query Log section in the Debugging guide for the full schema, debug-bar partial example, and coverage notes.

Best Practices

  • Keep models simple - Just extend Model, no configuration needed
  • Use meaningful class names - They become collection names automatically
  • Add validations - Validate data before it reaches the database
  • Use callbacks wisely - Keep them focused and avoid heavy operations
  • Add custom methods - Encapsulate business logic in model methods
  • Declare relationships - Use has_many, has_one, belongs_to, has_and_belongs_to_many for associations
  • Use includes for eager loading - Avoid N+1 queries when accessing related data
  • Use join for filtering - When you only need to filter by existence, not preload

Explore Further