Class: Cybertrain::Model

Inherits:
Object
  • Object
show all
Defined in:
cybertrain/model.rb,
cybertrain/template/interpreter.rb

Overview

Base class of every model.

You never subclass it by hand: spin run gen writes one subclass per table in db/schema.rb into gen/models/<singular>.rb (attribute accessors, casts, finders and the associations read off foreign keys; see Article for the full generated surface), and app/models/<singular>.rb reopens it, with no superclass, for validations, callbacks and your own methods:

# app/models/article.rb
class Article
  validates :title, presence: true
  validates :body, presence: true, length: { minimum: 10 }

  before_save { |article| article.title = article.title.strip }

  def summary = body[0, 80]
end

Re-run spin run gen after changing the schema (cybertrain db migrate and the development server do it for you).

Not available (compared with ActiveRecord): declared associations, has_many :through, includes, pluck, update_all, destroy_all, dirty tracking, transactions around save, STI, enums and the scope macro (write def self.published = where(published: true) instead).

Implementation: validations and callbacks are runtime data keyed by model name, since Spinel cannot define methods at runtime.

Direct Known Subclasses

Article, Comment

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#id ⇒ Integer (readonly)

The primary key, 0 until the record is first saved.

Returns:

  • (Integer)


199
200
201
# File 'cybertrain/model.rb', line 199

def id
  @id
end

Class Method Details

.after_create {|record| ... } ⇒ nil

Runs in #save of a new record, after the INSERT (the record has its id).

Yield Parameters:

Returns:

  • (nil)


135
136
137
138
139
140
# File 'cybertrain/model.rb', line 135

def self.after_create(&block) = add_model_callback("after_create", block)
# Runs in {#save} of a persisted record, after `before_save`, before the
# UPDATE.
# @yieldparam record [Model]
# @return [nil]
# @api public

.after_destroy {|record| ... } ⇒ nil

Runs in #destroy after the DELETE.

Yield Parameters:

Returns:

  • (nil)


159
# File 'cybertrain/model.rb', line 159

def self.after_destroy(&block) = add_model_callback("after_destroy", block)

.after_save {|record| ... } ⇒ nil

Runs in #save after the row is written and after after_create / after_update.

Yield Parameters:

Returns:

  • (nil)


125
126
127
128
129
# File 'cybertrain/model.rb', line 125

def self.after_save(&block) = add_model_callback("after_save", block)
# Runs in {#save} of a new record, after `before_save`, before the INSERT.
# @yieldparam record [Model]
# @return [nil]
# @api public

.after_update {|record| ... } ⇒ nil

Runs in #save of a persisted record, after the UPDATE.

Yield Parameters:

Returns:

  • (nil)


146
147
148
149
150
151
152
153
# File 'cybertrain/model.rb', line 146

def self.after_update(&block) = add_model_callback("after_update", block)
# Runs in {#destroy} before the DELETE. There is no `dependent:`; delete
# children here:
# @example
#   before_destroy { |article| Comment.where(article_id: article.id).delete_all }
# @yieldparam record [Model]
# @return [nil]
# @api public

.before_create {|record| ... } ⇒ nil

Runs in #save of a new record, after before_save, before the INSERT.

Yield Parameters:

Returns:

  • (nil)


130
131
132
133
134
# File 'cybertrain/model.rb', line 130

def self.before_create(&block) = add_model_callback("before_create", block)
# Runs in {#save} of a new record, after the INSERT (the record has its id).
# @yieldparam record [Model]
# @return [nil]
# @api public

.before_destroy {|record| ... } ⇒ nil

Runs in #destroy before the DELETE. There is no dependent:; delete children here:

Examples:

before_destroy { |article| Comment.where(article_id: article.id).delete_all }

Yield Parameters:

Returns:

  • (nil)


154
155
156
157
158
# File 'cybertrain/model.rb', line 154

def self.before_destroy(&block) = add_model_callback("before_destroy", block)
# Runs in {#destroy} after the DELETE.
# @yieldparam record [Model]
# @return [nil]
# @api public

.before_save {|record| ... } ⇒ nil

Runs in #save after validation passes, before the INSERT or UPDATE.

Yield Parameters:

Returns:

  • (nil)


119
120
121
122
123
124
# File 'cybertrain/model.rb', line 119

def self.before_save(&block) = add_model_callback("before_save", block)
# Runs in {#save} after the row is written and after `after_create` /
# `after_update`.
# @yieldparam record [Model]
# @return [nil]
# @api public

.before_update {|record| ... } ⇒ nil

Runs in #save of a persisted record, after before_save, before the UPDATE.

Yield Parameters:

Returns:

  • (nil)


141
142
143
144
145
# File 'cybertrain/model.rb', line 141

def self.before_update(&block) = add_model_callback("before_update", block)
# Runs in {#save} of a persisted record, after the UPDATE.
# @yieldparam record [Model]
# @return [nil]
# @api public

.before_validation {|record| ... } ⇒ nil

Runs at the start of every #valid? (so of every #save), after the errors are cleared and before the declared validations. Errors added here make the record invalid.

Examples:

before_validation do |post|
  taken = Post.where(slug: post.slug).where_sql("id != ?", [post.id]).exists?
  post.errors.add(:slug, "is taken") if taken
end

Yield Parameters:

Returns:

  • (nil)


114
115
116
117
118
# File 'cybertrain/model.rb', line 114

def self.before_validation(&block) = add_model_callback("before_validation", block)
# Runs in {#save} after validation passes, before the INSERT or UPDATE.
# @yieldparam record [Model]
# @return [nil]
# @api public

.validates(attr, presence: false, length: nil, allow_blank: false) ⇒ nil

Declares validations for one attribute, run by #valid? (and so by #save) in declaration order. Only presence and length exist.

  • presence: true adds "can't be blank" when the value is nil or a String that is empty after strip. false and 0 are present.
  • length: { minimum: n, maximum: m } (either key) adds "is too short (minimum is n characters)" or "is too long (maximum is m characters)". A nil value is left to presence; other keys (is:, in:) are ignored.
  • allow_blank: true skips the length rule for a blank value. It does not apply to presence.

For anything else (uniqueness, formats, cross-field rules), add errors yourself in a before_validation block: valid? clears the errors, runs that block, then the declared rules.

Examples:

validates :title, presence: true, length: { minimum: 3, maximum: 40 }
validates :subtitle, length: { maximum: 80 }, allow_blank: true

Parameters:

  • attr (Symbol) —

    the attribute (its SQL column name)

  • presence (Boolean) (defaults to: false)
  • length (Hash{Symbol => Integer}, nil) (defaults to: nil) —

    :minimum and/or :maximum

  • allow_blank (Boolean) (defaults to: false) —

    skip the length rule when the value is blank

Returns:

  • (nil)


83
84
85
86
87
88
89
90
91
92
# File 'cybertrain/model.rb', line 83

def self.validates(attr, presence: false, length: nil, allow_blank: false)
  list = (VALIDATORS[self.name] ||= Array.new(0) { Validator.new(:base, :presence) })
  list << Validator.new(attr, :presence) if presence
  unless length.nil?
    minimum = length.key?(:minimum) ? length[:minimum] : -1
    maximum = length.key?(:maximum) ? length[:maximum] : -1
    list << Validator.new(attr, :length, minimum, maximum, allow_blank)
  end
  nil
end

Instance Method Details

#==(other) ⇒ Boolean

Records are equal when they are the same model and the same saved row. Two unsaved records are never equal, not even a record to itself.

Parameters:

  • other (Object)

Returns:

  • (Boolean)


360
361
362
363
# File 'cybertrain/model.rb', line 360

def ==(other)
  return false unless other.is_a?(Model)
  @id != 0 && @id == other.id && model_name == other.model_name
end

#as_json ⇒ Hash{String => Object}

#attributes, ready for JSON: a Time becomes its ISO 8601 String ("2026-10-05T09:00:00Z"); booleans, numbers, Strings and nil stay as they are (Cast.to_sql would turn booleans into 1/0).

Returns:

  • (Hash{String => Object})


383
384
385
386
387
388
389
390
391
392
# File 'cybertrain/model.rb', line 383

def as_json
  out = {}
  attributes.each do |k, v|
    out[k] = case v
             when Time then Cast.iso8601(v)
             else v
             end
  end
  out
end

#attributes ⇒ Hash{String => Object}

Every column, keyed by its SQL name, "id" first, with typed values.

Examples:

article.attributes  # => {"id" => 1, "title" => "Hello", "body" => "...", "created_at" => 2026-10-05 09:00:00 UTC, ...}

Returns:

  • (Hash{String => Object})


370
371
372
373
374
375
376
# File 'cybertrain/model.rb', line 370

def attributes
  attrs = { "id" => read_attribute(:id) }
  self.class.column_names.each do |column|
    attrs[column] = read_attribute(column.to_sym) unless column == "id"
  end
  attrs
end

#destroy ⇒ Boolean

Runs before_destroy, deletes the row, runs after_destroy. The record keeps its attributes and id but is no longer #persisted?.

A record that was never saved has no row: destroy then runs no callbacks, sends no DELETE and returns false.

Returns:

  • (Boolean)


330
331
332
333
334
335
336
337
338
339
340
# File 'cybertrain/model.rb', line 330

def destroy
  return false unless @persisted

  run_callbacks("before_destroy")
  sql = "DELETE FROM #{Relation.quote_ident(self.class.table_name)} WHERE #{Relation.quote_ident("id")} = ?"
  id = @id
  Cybertrain::DB.with { |c| c.execute(sql, [id]) }
  @persisted = false
  run_callbacks("after_destroy")
  true
end

#errors ⇒ Errors

The validation messages from the last #valid? or #save.

Returns:



204
205
206
207
208
209
210
211
# File 'cybertrain/model.rb', line 204

def errors
  e = @errors
  return e unless e.nil?

  e = Errors.new
  @errors = e
  e
end

#new_record? ⇒ Boolean

Returns the opposite of #persisted?.

Returns:



241
242
243
# File 'cybertrain/model.rb', line 241

def new_record?
  !@persisted
end

#persisted? ⇒ Boolean

Returns true once the record has been saved or was loaded from the database, false after #destroy.

Returns:

  • (Boolean) —

    true once the record has been saved or was loaded from the database, false after #destroy



235
236
237
# File 'cybertrain/model.rb', line 235

def persisted?
  @persisted
end

#reload ⇒ self

Re-reads every column from the database.

Returns:

  • (self)

Raises:



346
347
348
349
350
351
352
353
# File 'cybertrain/model.rb', line 346

def reload
  sql = "SELECT * FROM #{Relation.quote_ident(self.class.table_name)} WHERE #{Relation.quote_ident("id")} = ?"
  id = @id
  found = Cybertrain::DB.with { |c| c.execute_models(sql, [id]) }
  raise RecordNotFound, "Couldn't find #{model_name} with id=#{id}" if found.empty?
  load_row(found[0])
  self
end

#save ⇒ Boolean

Validates, then runs before_save, before_create/before_update, writes the row, and runs after_create/after_update and after_save – the ActiveRecord order. Returns false (and writes nothing) when invalid.

A new record is INSERTed (created_at / updated_at are filled in when the table has them and they are nil) and gets its #id; a persisted one is UPDATEd, every column, with updated_at set to now. Timestamps are UTC, to the second.

Examples:

@article = Article.new(article_params)
if @article.save
  redirect_to article_path(@article), status: :see_other
else
  render :new, status: :unprocessable_entity
end

Returns:

  • (Boolean) —

    false when invalid (see #errors)

Raises:

  • (DB::Error) —

    when SQLite refuses the write (a NOT NULL or foreign key constraint, a missing column)



284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'cybertrain/model.rb', line 284

def save
  return false unless valid?

  run_callbacks("before_save")
  if @persisted
    run_callbacks("before_update")
    update_row
    run_callbacks("after_update")
  else
    run_callbacks("before_create")
    insert_row
    run_callbacks("after_create")
  end
  run_callbacks("after_save")
  true
end

#save! ⇒ true

Like #save, but raises instead of returning false.

Returns:

  • (true)

Raises:



305
306
307
308
# File 'cybertrain/model.rb', line 305

def save!
  raise RecordInvalid, "Validation failed: #{errors.full_messages.join(", ")}" unless save
  true
end

#to_json ⇒ String

#as_json as a JSON String, e.g. for render json: @article.to_json.

Returns:

  • (String)


397
398
399
# File 'cybertrain/model.rb', line 397

def to_json
  JSON.generate(as_json)
end

#to_param ⇒ String

The id as a String, what route helpers put in a URL ("0" for a new record).

Returns:

  • (String)


249
250
251
# File 'cybertrain/model.rb', line 249

def to_param
  @id.to_s
end

#update(attrs) ⇒ Boolean

Assigns the attributes (cast to the column types, unknown keys ignored) and saves.

Examples:

@article.update(article_params)

Parameters:

  • attrs (Hash{String, Symbol => Object}) —

    e.g. a permitted Params#permit Hash

Returns:

  • (Boolean) —

    the result of #save



318
319
320
321
# File 'cybertrain/model.rb', line 318

def update(attrs)
  assign_attributes(attrs)
  save
end

#valid? ⇒ Boolean

Clears #errors, runs the before_validation callbacks, then every rule declared with validates.

Returns:

  • (Boolean) —

    true when no errors were added



257
258
259
260
261
262
# File 'cybertrain/model.rb', line 257

def valid?
  errors.clear
  run_callbacks("before_validation")
  Model.validators_for(model_name).each { |v| v.validate(self) }
  errors.empty?
end