ESC
Type to search...
S
Soli Docs

Validations & Callbacks

Ensure data integrity with model validations and hook into the lifecycle of your records with callbacks.

Defining Validations

Use validates inside your model class to declare rules for each field. Validations run automatically before create and save operations.

class User < Model
  validates("email", { "presence": true, "uniqueness": true })
  validates("name", { "presence": true, "min_length": 2 })
  validates("age", { "numericality": true, "min": 0, "max": 150 })
  validates("password", { "min_length": 8 })
end

Multiple rules per field: You can combine any number of validation options in a single validates call. All rules for the field are checked together, and every failing rule produces its own error message.

Validation Options

Each option controls a specific check on the field value:

Option Description
presence: true Field must be present and not empty
uniqueness: true Value must be unique in collection
min_length: n String must be at least n characters
max_length: n String must be at most n characters
format: "regex" String must match regex pattern
numericality: true Value must be a number
min: n Number must be >= n
max: n Number must be <= n
custom: "method" Call custom validation method

Validation Results

Model.create() always returns an instance of the class. On validation or database failure, the returned instance is not persisted and its _errors field holds an array of { "field", "message" } entries. On success, _errors is nil.

user = User.create({ "email": "" })
if user._errors
  for error in user._errors
    print(error["field"] + ": " + error["message"])
  end
else
  print("Created user: " + user._key)
end

Tip: Always check user._errors before treating the record as persisted. When validations fail, the record is not persisted to the database.

Atomic Uniqueness

uniqueness: true issues a SELECT … LIMIT 1 before the write, but that check is best-effort: two concurrent User.create({ "email": "x" }) calls can both pass the SELECT and both insert. To make uniqueness atomic, declare a unique index on the column at deploy time and let the database enforce it. Soli detects the resulting 409 from Model.create, instance.save, instance.update, Model.upsert, and Model.find_or_create_by, and turns it into the same _errors entry the SELECT path produces (field: "has already been taken"), so callers handle the race identically.

# Run once at deploy time (e.g. in a migration):
solidb.create_index("users", "users_email_unique", ["email"], { "unique": true })

Without the index, the SELECT is the only line of defense and the race is silently lost — two parallel writers can both produce duplicate rows.

Custom Methods on Models

Define instance methods directly in your model class to encapsulate business logic. Use this to access the current instance's fields.

class User < Model
  def is_admin -> Bool
    this.role == "admin"
  end

  def full_name -> String
    this.first_name + " " + this.last_name
  end
end

user = User.find("user123")
if user.is_admin
  print("Welcome, admin " + user.full_name())
end

Zero-arg methods: Methods like is_admin that take no arguments can be called without parentheses (e.g. user.is_admin). Methods that return a value from a computation should use parentheses when called (e.g. user.full_name()).

Callbacks

Callbacks let you hook into the lifecycle of a model record. Declare them at the class level with the name of the instance method to call.

class User < Model
  before_save("normalize_email")
  after_create("send_welcome_email")
  before_update("log_changes")
  after_delete("cleanup_related")

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

  def send_welcome_email
    # Send email logic
  end
end

Execution order: before_save runs before both create and update operations, making it ideal for data normalization. Specific callbacks like before_create or before_update run only for their respective operation.

Available Callbacks

before_save

Before create or update

after_save

After create or update

before_create

Before inserting new record

after_create

After inserting new record

before_update

Before updating record

after_update

After updating record

before_delete

Before deleting record

after_delete

After deleting record

Firing order per persistence method

Both class-level methods (Model.create, Model.update) and instance-level mutators run the matching callbacks. Rails-style: _save callbacks fire on every persistence path, plus the more specific event for the operation. After-callbacks only fire when the persist call succeeds — if the native method returns false (validation or DB error) the after-callbacks are skipped and the instance carries _errors.

Method Before-callbacks (in order) DB write After-callbacks (in order)
Model.create(attrs)before_savebefore_createINSERTafter_createafter_save
Model.update(id, attrs)before_savebefore_updateUPDATEafter_updateafter_save
instance.save([attrs]) — new recordbefore_savebefore_createINSERTafter_createafter_save
instance.save([attrs]) — persistedbefore_savebefore_updateUPDATEafter_updateafter_save
instance.update(attrs)before_savebefore_updateUPDATEafter_updateafter_save
instance.restore()before_savebefore_updateUPDATEafter_updateafter_save
instance.increment(field, n?)before_savebefore_updateUPDATEafter_updateafter_save
instance.decrement(field, n?)before_savebefore_updateUPDATEafter_updateafter_save
instance.touch()before_savebefore_updateUPDATEafter_updateafter_save
instance.delete() (soft + hard)before_deleteUPDATE / DELETEafter_delete

Vetoing persistence from a before_* callback

Returning false from any before_* callback aborts the operation. The native DB write is skipped, after-callbacks don't run, and the instance picks up an _errors entry of the form [{ "message": "before_<event> callback returned false; persistence aborted" }]. Callers receive false (or, for Model.create / Model.update, the instance with _errors populated) so they can branch on the result identically to a validation failure.

class Audited < Model
  before_save("can_save")

  def can_save
    return false if User.current.is_nil?  # returns false → save() / update() aborts
  end
end

The veto fires at the first false — subsequent before-callbacks in the chain don't run. Use this for authorization gates, integrity checks, or any "deny by default" pattern. Mutating-only callbacks (the common case) just don't return false and run end-to-end as before.

Complete Example

class User < Model
  validates("email", { "presence": true, "uniqueness": true, "format": "^[^@]+@[^@]+$" })
  validates("name", { "presence": true, "min_length": 2, "max_length": 100 })
  validates("age", { "numericality": true, "min": 0, "max": 150 })
  validates("password", { "min_length": 8 })
  validates("role", { "custom": "validate_role" })

  before_save("normalize_email")
  after_create("send_welcome_email")
  before_delete("cleanup_related")

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

  def send_welcome_email
    # Send welcome email to new users
  end

  def cleanup_related
    # Remove associated data before deletion
  end

  def validate_role
    valid_roles = ["admin", "user", "moderator"]
    if !valid_roles.includes?(this.role)
      return "must be one of: admin, user, moderator"
    end
  end

  def is_admin -> Bool
    this.role == "admin"
  end

  def full_name -> String
    this.first_name + " " + this.last_name
  end
end

# Creating a user with validation
user = User.create({
  "email": "[email protected]",
  "name": "Alice",
  "age": 30,
  "password": "securepass",
  "role": "admin"
})

if !user._errors
  print(user.email)       # => "[email protected]" (normalized by before_save)
  print(user.is_admin)    # => true
  print(user.full_name()) # => "Alice "
end

Best Practices

  • Validate early - Add validations to catch bad data before it reaches the database
  • Use presence checks - Required fields should always have presence: true
  • Keep callbacks focused - Each callback method should do one thing well
  • Avoid heavy logic in callbacks - Don't put slow operations in before_save or before_create
  • Use custom validations - For complex rules that can't be expressed with built-in options
  • Check validation results - Always inspect user._errors after create

Next Steps