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. 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.
CRUD Operations
Auto-creation: All Model operations automatically create the collection if it doesn't exist. This only happens on the first call that encounters a missing collection.
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
let result = User.create({
"email": "[email protected]",
"name": "Alice",
"age": 30
})
# Returns: { "valid": true, "record": }
# Or: { "valid": false, "errors": [...] }
let user = result["record"]
user.name # => "Alice"
user._key # => "abc123" (auto-generated)
READ Finding Records
# Find by ID - returns a User instance
let user = User.find("user123")
user.name # => "Alice"
# Find all - returns array of User instances
let users = User.all
users.first.name # => "Alice"
# Find with filter - returns array of User instances
let adults = User.where("age >= @age", { "age": 18 }).all
# Complex conditions
let results = User.where("age >= @min AND role == @role", {
"min": 21,
"role": "admin"
}).all
UPDATE Updating Records
# Static method: update by ID
User.update("user123", { "name": "Alice Smith", "age": 31 })
# Instance method: modify fields and save
let 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()
DELETE Deleting Records
# Static method
User.delete("user123")
# Instance method
let user = User.find("user123")
user.delete()
COUNT Counting Records
let total = User.count
Instance Methods
All model instances have built-in methods for persistence:
| Method | Description |
|---|---|
instance.save() |
Insert (if new) or update (if existing). Returns the instance with _key populated. |
instance.update() |
Persist current non-_ fields to DB. Requires _key. |
instance.delete() |
Delete the document from DB. Requires _key. Supports soft delete. |
instance.reload() |
Refresh instance from database. |
instance.increment(field) |
Atomically increment a numeric field by 1 (or specified amount). |
instance.decrement(field) |
Atomically decrement a numeric field by 1 (or specified amount). |
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. |
# Create a new instance and save it
let user = User.new()
user.name = "Bob"
user.email = "[email protected]"
user.save()
# user._key is now set
# Modify and save again
user.name = "Robert"
user.save()
# Delete
user.delete()
Read-only fields: Fields starting with _ (_key, _id, _rev, etc.) are read-only on model instances. They are set automatically by the database and cannot be assigned directly.
Static Methods Reference
| Method | Description |
|---|---|
Model.create(data) |
Insert a new document, returns { "valid": bool, "record": instance } |
Model.create_many([data, ...]) |
Batch insert multiple documents, returns { "created": n } |
Model.find(id) |
Get document by ID, returns instance or null |
Model.find_by(field, value) |
Find first record by field value |
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.where(filter, bind_vars) |
Query with SDBQL filter |
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.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.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) |
Group-by aggregation (returns QueryBuilder, chain .all) |
Model.pluck(field, ...) |
Select fields only (returns QueryBuilder, chain .all) |
Complete Example
# app/models/user.sl
class User < Model
has_many("posts")
has_one("profile")
validates("email", { "presence": true, "uniqueness": true })
validates("name", { "presence": true, "min_length": 2 })
before_save("normalize_email")
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
let users = User.includes("posts", "profile").all
render("users/index", { "users": users })
end
def show(req: Any)
let id = req["params"]["id"]
let 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
let 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)
let result = User.create({
"name": req["params"]["name"],
"email": req["params"]["email"]
})
if result["valid"]
let user = result["record"]
return redirect("/users/" + user._key)
else
render("users/new", { "errors": result["errors"] })
end
end
def update(req: Any)
let user = User.find(req["params"]["id"])
user.name = req["params"]["name"]
user.save()
redirect("/users/" + user._key)
end
def destroy(req: Any)
let user = User.find(req["params"]["id"])
user.delete()
redirect("/users")
end
end
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_tofor associations - Use
includesfor eager loading - Avoid N+1 queries when accessing related data - Use
joinfor filtering - When you only need to filter by existence, not preload
Explore Further
Query Builder →
Chain methods to build complex queries with filters, ordering, limits, and more.
Relationships →
has_many, has_one, belongs_to, eager loading with includes, and join filtering.
Validations & Callbacks →
Validate data before saving and run lifecycle callbacks.
Finders & Aggregations →
find_by, pluck, exists, sum, avg, min, max, and group_by.
Advanced Features →
Soft delete, scopes, batch operations, transactions, and raw queries.
Migrations →
Create collections and indexes with database migrations.