Class: Tina4::ORM
Direct Known Subclasses
Realtime::Attachment, Realtime::Channel, Realtime::ChannelMember, Realtime::Message, Realtime::Workspace
Class Method Summary collapse
- .all(limit: 100, offset: nil, order_by: nil, include: nil) ⇒ Object
-
.auto_crud ⇒ Object
auto_crud flag — when set to true, the class registers itself with Tina4::AutoCrud which auto-generates REST endpoints from the model.
- .auto_crud=(val) ⇒ Object
-
.auto_map ⇒ Object
Auto-map flag — defaults to TRUE for cross-framework parity (Python's ORM has auto_map=True by default).
- .auto_map=(val) ⇒ Object
-
.belongs_to(name, class_name: nil, foreign_key: nil) ⇒ Object
belongs_to :user, class_name: "User", foreign_key: "user_id".
-
.cached(sql, params = [], ttl: 60, limit: 100, offset: nil, include: nil) ⇒ Object
SQL query with result caching.
-
.clear_cache ⇒ Object
Invalidate every cached query for THIS model.
-
.clear_rel_cache ⇒ Object
Clear the relationship cache on all loaded instances (class-level helper).
- .count(conditions = nil, params = []) ⇒ Object
-
.create(attributes = {}) ⇒ Object
Create a new instance, save it, and return it.
- .create_table ⇒ Object
- .db ⇒ Object
-
.db=(database) ⇒ Object
Per-model database binding.
-
.eager_load(instances, include_list) ⇒ Object
Eager load relationships for a collection of instances (prevents N+1).
-
.exists(id) ⇒ Object
Return true if a record with the given primary key exists.
-
.field_mapping ⇒ Object
Field mapping: { 'db_column' => 'ruby_attribute' }.
- .field_mapping=(map) ⇒ Object
- .find(id_or_filter = nil, filter = nil, **kwargs) ⇒ Object
-
.find_by_id(id) ⇒ Object
find_by_id is PUBLIC — cross-framework parity with Python's MyModel.find_by_id(pk_value) and PHP's User::find($id).
- .find_or_fail(id) ⇒ Object
- .from_hash(hash) ⇒ Object
-
.get_db ⇒ Object
Return the database connection used by this model.
-
.get_db_column(property) ⇒ Object
Map a Ruby property name to its database column name using field_mapping.
-
.has_many(name, class_name: nil, foreign_key: nil) ⇒ Object
has_many :posts, class_name: "Post", foreign_key: "user_id".
-
.has_one(name, class_name: nil, foreign_key: nil) ⇒ Object
has_one :profile, class_name: "Profile", foreign_key: "user_id".
-
.inherited(subclass) ⇒ Object
When a new model class is defined, resolve any deferred ForeignKeyField wiring that targets it.
-
.model_subclasses ⇒ Object
Every Tina4::ORM subclass that has been loaded, in definition order.
-
.query ⇒ Tina4::QueryBuilder
Create a fluent QueryBuilder pre-configured for this model's table and database.
-
.query_cache ⇒ Object
The ONE process-wide query cache, shared by every model.
-
.relationship_definitions ⇒ Object
Relationship definitions.
- .scope(name, filter_sql, params = []) ⇒ Object
- .select(sql, params = [], limit: 100, offset: nil, include: nil) ⇒ Object
- .select_one(sql, params = [], include: nil) ⇒ Object
-
.soft_delete ⇒ Object
Soft delete configuration.
- .soft_delete=(val) ⇒ Object
- .soft_delete_field ⇒ Object
- .soft_delete_field=(val) ⇒ Object
- .where(conditions, params = [], limit: 100, offset: nil, order_by: nil, include: nil) ⇒ Object
- .with_trashed(conditions = "1=1", params = [], limit: 100, offset: 0) ⇒ Object
Instance Method Summary collapse
-
#delete ⇒ Object
Delete this record (soft or hard).
- #errors ⇒ Object
- #force_delete ⇒ Object
-
#get_error ⇒ Object
Return the cause of the most recent failed #save, or nil.
-
#initialize(attributes = {}) ⇒ ORM
constructor
A new instance of ORM.
-
#last_error ⇒ Object
Cause of the most recent failed #save (validation message or DB error), or nil when the last save succeeded.
-
#load(arg = nil, params = nil) ⇒ Object
load — populate this instance from the database.
- #persisted? ⇒ Boolean
-
#pk_filter ⇒ Object
Insert or update.
- #restore ⇒ Object
- #save ⇒ Object
- #select(*fields) ⇒ Object
- #to_array ⇒ Object (also: #to_list)
-
#to_h(include: nil, case: nil) ⇒ Object
(also: #to_hash, #to_dict, #to_object)
Convert to hash using Ruby attribute names.
- #to_json(include: nil, **_args) ⇒ Object
- #to_s ⇒ Object
- #validate ⇒ Object
Methods included from FieldTypes
Constructor Details
#initialize(attributes = {}) ⇒ ORM
Returns a new instance of ORM.
690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 |
# File 'lib/tina4/orm.rb', line 690 def initialize(attributes = {}) @persisted = false @errors = [] # Cause of the most recent failed #save (validation message or DB error). # nil when the most recent save succeeded. Mirrors db.get_error so a caller # that checks `return false unless model.save` can still recover the real # cause via #get_error / #last_error — the failure never vanishes silently. @last_error = nil @relationship_cache = {} # #165: field names the caller EXPLICITLY assigned (via the attribute loop # below, a from_hash/load populate, or a later `model.field = x`). save() # reads this to OMIT an unset column from an INSERT (so a NOT NULL DEFAULT # column gets its DB default) while still writing NULL for a field the # caller set to nil. The defaults seeded below use instance_variable_set, # bypassing the tracking setter so they are NOT counted as assignments. @assigned_fields = [] # Accept a JSON object string (parity with Python/PHP/Node): # Widget.new('{"id":1,"name":"alpha"}') attributes = JSON.parse(attributes) if attributes.is_a?(String) # A single model is one record — reject an Array with a clear message. if attributes.is_a?(Array) raise ArgumentError, "#{self.class}.new expects a Hash, keyword args, or a JSON object string " \ "for one record — got an Array. Map over the list to build many records." end attributes.each do |key, value| setter = "#{key}=" __send__(setter, value) if respond_to?(setter) end # Set defaults. # v3.13.11 (issue #50.1): when the default is a Proc/lambda # (``default: -> { Time.now }``), call it per-instance so # per-row timestamps actually differ. Class objects are # excluded — ``default: Integer`` is almost never intended # to mean ``Integer.new`` (and Integer has no zero-arg # constructor anyway). self.class.field_definitions.each do |name, opts| if __send__(name).nil? && opts[:default] d = opts[:default] d = d.call if d.respond_to?(:call) && !d.is_a?(Class) # Deep-copy a mutable Hash/Array default so two instances never alias # the same object (e.g. `json_field :meta, default: {}` — mutating # a.meta must not leak into b.meta). Parity with the Python master, # which deepcopies a JSONField's dict/list default per instance. d = Marshal.load(Marshal.dump(d)) if d.is_a?(Hash) || d.is_a?(Array) # #165: seed the default straight into the ivar, BYPASSING the # tracking setter, so a default is not recorded as a caller # assignment (mirrors the Python master's object.__setattr__). instance_variable_set("@#{name}", d) end end end |
Class Method Details
.all(limit: 100, offset: nil, order_by: nil, include: nil) ⇒ Object
335 336 337 338 339 340 341 342 343 344 345 |
# File 'lib/tina4/orm.rb', line 335 def all(limit: 100, offset: nil, order_by: nil, include: nil) sql = "SELECT * FROM #{table_name}" if soft_delete sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0" end sql += " ORDER BY #{order_by}" if order_by results = db.fetch(sql, [], limit: limit, offset: offset) instances = results.map { |row| from_hash(row) } eager_load(instances, include) if include instances end |
.auto_crud ⇒ Object
auto_crud flag — when set to true, the class registers itself with Tina4::AutoCrud which auto-generates REST endpoints from the model. Defaults to false. Cross-framework parity with Python's autoCrud.
150 151 152 |
# File 'lib/tina4/orm.rb', line 150 def auto_crud defined?(@auto_crud) && !@auto_crud.nil? ? @auto_crud : false end |
.auto_crud=(val) ⇒ Object
154 155 156 157 158 159 |
# File 'lib/tina4/orm.rb', line 154 def auto_crud=(val) @auto_crud = val if val && defined?(::Tina4::AutoCrud) ::Tina4::AutoCrud.models << self unless ::Tina4::AutoCrud.models.include?(self) end end |
.auto_map ⇒ Object
Auto-map flag — defaults to TRUE for cross-framework parity (Python's
ORM has auto_map=True by default). The instance variable is treated
as "unset" when nil; only an explicit false disables it.
INERT IN RUBY, DELIBERATELY. Nothing reads this flag: Ruby is
snake_case-native, so the attribute name a developer writes IS the
column name and there is nothing for a case mapping to do. It exists
only so a model ported from PHP (where autoMap really does map a
camelCase property onto a snake_case column) does not blow up on an
unknown setter. Setting it either way changes NOTHING.
This is not an oversight to "fix" by adding conversion: the owner's
naming rule (2026-07-29) is that the column name must mirror the
DATABASE, and a language-specific case mapping may only ever be an
OPT-IN. Adding camel->snake here by default would be that mapping, on
by default, which is the opposite. Use field_mapping to point an
attribute at a differently-named column — that is the supported
mechanism, and spec/orm_column_case_spec.rb pins all of this so the
flag cannot quietly grow behaviour later.
139 140 141 |
# File 'lib/tina4/orm.rb', line 139 def auto_map defined?(@auto_map) && !@auto_map.nil? ? @auto_map : true end |
.auto_map=(val) ⇒ Object
143 144 145 |
# File 'lib/tina4/orm.rb', line 143 def auto_map=(val) @auto_map = val end |
.belongs_to(name, class_name: nil, foreign_key: nil) ⇒ Object
belongs_to :user, class_name: "User", foreign_key: "user_id"
198 199 200 201 202 203 204 205 206 207 208 |
# File 'lib/tina4/orm.rb', line 198 def belongs_to(name, class_name: nil, foreign_key: nil) relationship_definitions[name] = { type: :belongs_to, class_name: class_name || name.to_s.split("_").map(&:capitalize).join, foreign_key: foreign_key || "#{name}_id" } define_method(name) do load_belongs_to(name) end end |
.cached(sql, params = [], ttl: 60, limit: 100, offset: nil, include: nil) ⇒ Object
SQL query with result caching. Returns an array of ORM instances.
Parity with the Python master's cached (orm/model.py:1077): same key
shape, same tag, and a miss delegates to select so eager loading and the
row cap behave identically to an uncached read.
Entries are tagged with the model name so #clear_cache invalidates only this model's queries and leaves every other model's cached reads intact.
381 382 383 384 385 386 387 388 389 390 391 392 393 394 |
# File 'lib/tina4/orm.rb', line 381 def cached(sql, params = [], ttl: 60, limit: 100, offset: nil, include: nil) key = "#{name}:#{QueryCache.query_key(sql, params)}:#{limit}:#{offset || 0}" # nil-check, NOT a truthiness check: a query that legitimately returns no # rows caches an empty array, and that is a HIT. Treating it as a miss # would re-run the query on every call for exactly the queries where # caching pays off most. hit = query_cache.get(key) return hit unless hit.nil? result = select(sql, params, limit: limit, offset: offset, include: include) query_cache.set(key, result, ttl: ttl, tags: [name]) result end |
.clear_cache ⇒ Object
Invalidate every cached query for THIS model. Tag-scoped, so it never
flushes another model's entries. Mirrors the master's
_query_cache.clear_tag(cls.__name__).
399 400 401 402 |
# File 'lib/tina4/orm.rb', line 399 def clear_cache query_cache.clear_tag(name) nil end |
.clear_rel_cache ⇒ Object
Clear the relationship cache on all loaded instances (class-level helper). Useful after bulk operations when you want to force relationship re-loads.
632 633 634 635 |
# File 'lib/tina4/orm.rb', line 632 def clear_rel_cache # -> nil @_rel_cache = {} nil end |
.count(conditions = nil, params = []) ⇒ Object
404 405 406 407 408 409 410 411 412 413 414 |
# File 'lib/tina4/orm.rb', line 404 def count(conditions = nil, params = []) sql = "SELECT COUNT(*) as cnt FROM #{table_name}" where_parts = [] if soft_delete where_parts << "(#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)" end where_parts << "(#{conditions})" if conditions sql += " WHERE #{where_parts.join(' AND ')}" unless where_parts.empty? result = db.fetch_one(sql, params) result[:cnt].to_i end |
.create(attributes = {}) ⇒ Object
Create a new instance, save it, and return it.
Returns the saved instance on success. v3.13.39: if the underlying #save fails (validation errors or a driver error), create returns false — it does NOT hand back a possibly-unsaved instance, so a failed insert can never masquerade as a success. The failure cause is logged and available on the (discarded) instance's #get_error via the same path save uses. Parity with the Python master.
424 425 426 427 428 |
# File 'lib/tina4/orm.rb', line 424 def create(attributes = {}) instance = new(attributes) return false if instance.save == false instance end |
.create_table ⇒ Object
442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 |
# File 'lib/tina4/orm.rb', line 442 def create_table return true if db.table_exists?(table_name) # v3.13.16: engine-aware DDL. Ruby used to emit SQLite-only DDL on # every driver — INTEGER for booleans, DATETIME for datetimes, and a # raw AUTOINCREMENT keyword — then ignore db.execute()'s return value # and report success. On PostgreSQL the CREATE blew up # ("syntax error at or near AUTOINCREMENT"), db.execute() swallowed it # into get_error() and returned false, yet create_table still returned # true with no table created — a silent, misleading pass. # # The fix mirrors the Python reference (tina4_python.orm.model): # • get_database_type() now exists on Database (it didn't before, so # the v3.13.11 BooleanField check never actually fired on Ruby). # • BooleanField → native BOOLEAN (PG/MySQL) / BIT (MSSQL) / # INTEGER (sqlite, firebird) — both PG aliases are matched. # • DateTimeField → TIMESTAMP on PG/Firebird (neither has a DATETIME # type), DATETIME elsewhere. # • boolean DEFAULT is engine-aware: TRUE/FALSE for a native BOOLEAN, # 1/0 for INTEGER/BIT-backed bools. # • AUTOINCREMENT is translated per engine via SQLTranslator # (SERIAL on PG, AUTO_INCREMENT on MySQL, IDENTITY on MSSQL, dropped # on Firebird) instead of being emitted raw. # • return false (not true) when the DDL fails. engine = (db.respond_to?(:get_database_type) ? db.get_database_type : "").to_s.downcase bool_sql = case engine when "postgres", "postgresql" then "BOOLEAN" when "mysql" then "BOOLEAN" # alias for TINYINT(1) when "mssql", "sqlserver" then "BIT" else "INTEGER" # sqlite, firebird, odbc, anything else end # PostgreSQL and Firebird have no DATETIME type — CREATE TABLE fails # with `type "datetime" does not exist`. Emit each engine's real # timestamp type. (MySQL/MSSQL/SQLite keep DATETIME: valid there, and # on MySQL it avoids TIMESTAMP's auto-update + 2038 surprises.) datetime_sql = case engine when "postgres", "postgresql", "firebird" then "TIMESTAMP" else "DATETIME" end # Engine-aware JSON column type (parity with the Python master's # JSONField DDL). PostgreSQL gets native JSONB (indexable, canonical); # MySQL native JSON; MSSQL stores JSON as NVARCHAR(MAX) (its JSON # functions read that); Firebird has no TEXT type so it uses a text # BLOB; SQLite and everything else store the JSON text in TEXT # (queryable via json1). json_sql = case engine when "postgres", "postgresql" then "JSONB" when "mysql" then "JSON" when "mssql", "sqlserver" then "NVARCHAR(MAX)" when "firebird" then "BLOB SUB_TYPE TEXT" else "TEXT" end type_map = { integer: "INTEGER", string: "VARCHAR(255)", text: "TEXT", float: "REAL", decimal: "REAL", boolean: bool_sql, date: "DATE", datetime: datetime_sql, timestamp: "TIMESTAMP", blob: "BLOB", json: json_sql } col_defs = [] field_definitions.each do |name, opts| sql_type = type_map[opts[:type]] || "TEXT" if opts[:type] == :string && opts[:length] sql_type = "VARCHAR(#{opts[:length]})" end parts = ["#{name} #{sql_type}"] # A COMPOSITE key is declared ONCE, at table level (below). An inline # PRIMARY KEY per column is invalid DDL - SQLite, PostgreSQL and MySQL # all reject two of them in one table, so a composite-key model could # not create its own table at all. parts << "PRIMARY KEY" if opts[:primary_key] && primary_key_fields.length == 1 parts << "AUTOINCREMENT" if opts[:auto_increment] parts << "NOT NULL" if !opts[:nullable] && !opts[:primary_key] # A JSON column carries no DDL DEFAULT (parity with the Python master): # a dict/list default is an application-level concern, applied per # instance, not a portable SQL literal (PG needs a ::jsonb cast, MySQL # an expression default). The instance still gets its default at new. # A callable default (e.g. `datetime_field :created_at, default: -> { Time.now }`) # is resolved per-row at instance creation; it must NOT reach the DDL, where # default_literal would stringify the Proc to `DEFAULT #<Proc:...>` — invalid # SQL that silently fails table creation (parity with the Python master, #61). callable_default = opts[:default].respond_to?(:call) && !opts[:default].is_a?(Class) if opts[:default] && !opts[:auto_increment] && opts[:type] != :json && !callable_default parts << "DEFAULT #{default_literal(opts[:default], opts[:type], bool_sql)}" end col_defs << parts.join(" ") end # A COMPOSITE key is declared ONCE, at table level; the per-column inline # form above is suppressed for it. if primary_key_fields.length > 1 col_defs << "PRIMARY KEY (#{primary_key_fields.join(', ')})" end sql = "CREATE TABLE IF NOT EXISTS #{table_name} (#{col_defs.join(', ')})" # Translate AUTOINCREMENT to the engine's auto-increment syntax # (INTEGER PRIMARY KEY AUTOINCREMENT -> SERIAL PRIMARY KEY on PG, etc.). # SQLTranslator keys off the -ql spelling for postgres. translator_engine = %w[postgres postgresql].include?(engine) ? "postgresql" : engine sql = SQLTranslator.auto_increment_syntax(sql, translator_engine) # Don't claim success when the DDL failed. db.execute() now RAISES on a # SQL error (it no longer swallows it into get_error() and returns # false), so a bad type (or any DDL error) surfaces here as an # exception. create_table keeps its documented bool contract: catch the # raise, log the cause, and return false so callers that test the return # still see a clean failure instead of a thrown error. begin db.execute(sql) db.commit true rescue => e Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error || e.}", { sql: sql }) false end end |
.db ⇒ Object
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/tina4/orm.rb', line 63 def db # Resolution order: # 1. @db is a Symbol/String → named connection from Tina4.databases # (bound via Tina4.bind_database(db, name:)). Raises a clear # error if that named connection was never registered. # 2. @db is a Database/driver instance → use it directly. # 3. Otherwise → global Tina4.database, else env-derived # auto-discovery (TINA4_DATABASE_URL). v3.13.12 wired this # fallback; before that auto_discover_db was never called. case @db when Symbol, String name = @db.to_sym Tina4.databases[name] || raise( "Tina4 named database connection '#{@db}' is not registered for #{name}. " \ "Call Tina4.bind_database(db, name: #{@db.inspect}) before using this model." ) when nil Tina4.database || auto_discover_db else @db end end |
.db=(database) ⇒ Object
Per-model database binding. self.db = some_database_instance → use that connection self.db = :analytics → resolve a named connection from Tina4.databases at access time
90 91 92 |
# File 'lib/tina4/orm.rb', line 90 def db=(database) @db = database end |
.eager_load(instances, include_list) ⇒ Object
Eager load relationships for a collection of instances (prevents N+1). include is an array of relationship names, supporting dot notation for nesting.
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 |
# File 'lib/tina4/orm.rb', line 245 def eager_load(instances, include_list) return if instances.nil? || instances.empty? # Group includes: top-level and nested top_level = {} include_list.each do |inc| parts = inc.to_s.split(".", 2) rel_name = parts[0].to_sym top_level[rel_name] ||= [] top_level[rel_name] << parts[1] if parts.length > 1 end top_level.each do |rel_name, nested| rel = relationship_definitions[rel_name] next unless rel klass = Object.const_get(rel[:class_name]) pk = primary_key_field || :id case rel[:type] when :has_one, :has_many fk = rel[:foreign_key] || "#{name.split('::').last.downcase}_id" pk_values = instances.map { |inst| inst.__send__(pk) }.compact.uniq next if pk_values.empty? placeholders = pk_values.map { "?" }.join(",") sql = "SELECT * FROM #{klass.table_name} WHERE #{fk} IN (#{placeholders})" results = klass.db.fetch(sql, pk_values) = results.map { |row| klass.from_hash(row) } # Eager load nested klass.eager_load(, nested) unless nested.empty? # Group by FK grouped = {} .each do |record| fk_val = record.__send__(fk.to_sym) if record.respond_to?(fk.to_sym) (grouped[fk_val] ||= []) << record end instances.each do |inst| pk_val = inst.__send__(pk) records = grouped[pk_val] || [] if rel[:type] == :has_one inst.instance_variable_get(:@relationship_cache)[rel_name] = records.first else inst.instance_variable_get(:@relationship_cache)[rel_name] = records end end when :belongs_to fk = rel[:foreign_key] || "#{rel_name}_id" fk_values = instances.map { |inst| inst.respond_to?(fk.to_sym) ? inst.__send__(fk.to_sym) : nil }.compact.uniq next if fk_values.empty? = klass.primary_key_field || :id placeholders = fk_values.map { "?" }.join(",") sql = "SELECT * FROM #{klass.table_name} WHERE #{} IN (#{placeholders})" results = klass.db.fetch(sql, fk_values) = results.map { |row| klass.from_hash(row) } klass.eager_load(, nested) unless nested.empty? lookup = {} .each { |r| lookup[r.__send__()] = r } instances.each do |inst| fk_val = inst.respond_to?(fk.to_sym) ? inst.__send__(fk.to_sym) : nil inst.instance_variable_get(:@relationship_cache)[rel_name] = lookup[fk_val] end end end end |
.exists(id) ⇒ Object
Return true if a record with the given primary key exists.
Cross-framework parity with Python's MyModel.exists(pk_value), PHP's Model::exists($id), and Node's Model.exists(pk). Honours the soft-delete filter the same way find_by_id does (it routes through it). Used by #save to decide INSERT vs UPDATE for natural (non-auto-increment) primary keys — see the note on #save.
626 627 628 |
# File 'lib/tina4/orm.rb', line 626 def exists(id) !find_by_id(id).nil? end |
.field_mapping ⇒ Object
Field mapping: { 'db_column' => 'ruby_attribute' }
112 113 114 |
# File 'lib/tina4/orm.rb', line 112 def field_mapping @field_mapping || {} end |
.field_mapping=(map) ⇒ Object
116 117 118 |
# File 'lib/tina4/orm.rb', line 116 def field_mapping=(map) @field_mapping = map end |
.find(id_or_filter = nil, filter = nil, **kwargs) ⇒ Object
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 |
# File 'lib/tina4/orm.rb', line 220 def find(id_or_filter = nil, filter = nil, **kwargs) include_list = kwargs.delete(:include) # find(id) — find by primary key # find(filter_hash) — find by criteria # find(name: "Alice") — keyword args as filter hash result = if id_or_filter.is_a?(Hash) find_by_filter(id_or_filter) elsif filter.is_a?(Hash) find_by_filter(filter) elsif !kwargs.empty? find_by_filter(kwargs) else find_by_id(id_or_filter) end if include_list && result instances = result.is_a?(Array) ? result : [result] eager_load(instances, include_list) end result end |
.find_by_id(id) ⇒ Object
find_by_id is PUBLIC — cross-framework parity with Python's MyModel.find_by_id(pk_value) and PHP's User::find($id). Spec at spec/orm_spec.rb:78 verifies public access. find_by_filter stays public for the same reason; both are part of the documented API.
610 611 612 613 614 615 616 617 |
# File 'lib/tina4/orm.rb', line 610 def find_by_id(id) pk = primary_key_field || :id sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?" if soft_delete sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)" end select_one(sql, [id]) end |
.find_or_fail(id) ⇒ Object
430 431 432 433 434 |
# File 'lib/tina4/orm.rb', line 430 def find_or_fail(id) result = find(id) raise "#{name} with #{primary_key_field || :id}=#{id} not found" if result.nil? result end |
.from_hash(hash) ⇒ Object
578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 |
# File 'lib/tina4/orm.rb', line 578 def from_hash(hash) instance = new mapping_reverse = field_mapping.invert hash.each do |key, value| # Apply field mapping (db_col => ruby_attr) attr_name = mapping_reverse[key.to_s] || key # A JSON column comes back from the driver as a JSON string (SQLite # TEXT, MySQL JSON, PostgreSQL JSONB via the text protocol, MSSQL # NVARCHAR). Decode it to the Hash/Array the attribute expects # (parity with the Python master's JSONField parse-on-read). A value # already a Hash/Array is left untouched; nil stays nil; a # non-decodable string keeps its raw form rather than crashing a # normal read. fdef = field_definitions[attr_name.to_sym] if fdef && fdef[:type] == :json && value.is_a?(String) begin value = JSON.parse(value) rescue JSON::ParserError # leave the raw string in place end end setter = "#{attr_name}=" instance.__send__(setter, value) if instance.respond_to?(setter) end instance.instance_variable_set(:@persisted, true) instance end |
.get_db ⇒ Object
Return the database connection used by this model.
638 639 640 |
# File 'lib/tina4/orm.rb', line 638 def get_db # -> Database db end |
.get_db_column(property) ⇒ Object
Map a Ruby property name to its database column name using field_mapping. Returns the column name as a symbol.
644 645 646 647 |
# File 'lib/tina4/orm.rb', line 644 def get_db_column(property) # -> Symbol col = field_mapping[property.to_s] || property col.to_sym end |
.has_many(name, class_name: nil, foreign_key: nil) ⇒ Object
has_many :posts, class_name: "Post", foreign_key: "user_id"
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/tina4/orm.rb', line 180 def has_many(name, class_name: nil, foreign_key: nil) relationship_definitions[name] = { type: :has_many, # Derive the target class from the (plural) relationship name via a # proper singularizer — "posts" → "Post", "categories" → "Category" # — instead of the naive sub(/s$/) that produced "Categorie". The FK # auto-wire path (foreign_key_field) always passes class_name: # explicitly, so this default only applies to a hand-written has_many. class_name: class_name || Tina4.singularize(name).split("_").map(&:capitalize).join, foreign_key: foreign_key } define_method(name) do load_has_many(name) end end |
.has_one(name, class_name: nil, foreign_key: nil) ⇒ Object
has_one :profile, class_name: "Profile", foreign_key: "user_id"
167 168 169 170 171 172 173 174 175 176 177 |
# File 'lib/tina4/orm.rb', line 167 def has_one(name, class_name: nil, foreign_key: nil) relationship_definitions[name] = { type: :has_one, class_name: class_name || name.to_s.split("_").map(&:capitalize).join, foreign_key: foreign_key } define_method(name) do load_has_one(name) end end |
.inherited(subclass) ⇒ Object
When a new model class is defined, resolve any deferred ForeignKeyField
wiring that targets it. The string / forward-reference form of
foreign_key_field (e.g. references: "Author") records the has_many
side in @@_fk_registry but cannot wire it until the referenced class
actually loads — which is now. Without this hook apply_fk_registry! was
never called, so the has_many side silently never wired. The class body
(where the model's own foreign_key_field declarations run, populating the
registry) executes AFTER inherited returns, so entries keyed on THIS
class were already recorded by earlier-loaded models. Chain through super
so we never clobber a future inherited hook.
49 50 51 52 53 |
# File 'lib/tina4/orm.rb', line 49 def self.inherited(subclass) super (@_model_subclasses ||= []) << subclass subclass.apply_fk_registry! if subclass.respond_to?(:apply_fk_registry!, true) end |
.model_subclasses ⇒ Object
Every Tina4::ORM subclass that has been loaded, in definition order. Mirrors Python's ORM.subclasses() — used to resolve string-form ForeignKeyField references to a live class.
58 59 60 |
# File 'lib/tina4/orm.rb', line 58 def self.model_subclasses @_model_subclasses ||= [] end |
.query ⇒ Tina4::QueryBuilder
Create a fluent QueryBuilder pre-configured for this model's table and database.
Usage:
results = User.query.where("active = ?", [1]).order_by("name").get
216 217 218 |
# File 'lib/tina4/orm.rb', line 216 def query QueryBuilder.from_table(table_name, db: db) end |
.query_cache ⇒ Object
The ONE process-wide query cache, shared by every model.
The Python master holds this as a module-level _query_cache = Cache(default_ttl=0, max_size=500) in orm/model.py, so every model shares
a single store. A plain @query_cache ||= here would NOT be that
contract: class << self ivars are per-class, so each subclass would get
its own cache and User.clear_cache would silently leave Order's entries
alone. Anchoring the ivar on ORM itself keeps one store for all models,
however deep the subclass.
368 369 370 371 |
# File 'lib/tina4/orm.rb', line 368 def query_cache ORM.instance_variable_get(:@query_cache) || ORM.instance_variable_set(:@query_cache, QueryCache.new(default_ttl: 0, max_size: 500)) end |
.relationship_definitions ⇒ Object
Relationship definitions
162 163 164 |
# File 'lib/tina4/orm.rb', line 162 def relationship_definitions @relationship_definitions ||= {} end |
.scope(name, filter_sql, params = []) ⇒ Object
572 573 574 575 576 |
# File 'lib/tina4/orm.rb', line 572 def scope(name, filter_sql, params = []) define_singleton_method(name) do |limit: 100, offset: 0| where(filter_sql, params, limit: limit, offset: offset) end end |
.select(sql, params = [], limit: 100, offset: nil, include: nil) ⇒ Object
347 348 349 350 351 352 |
# File 'lib/tina4/orm.rb', line 347 def select(sql, params = [], limit: 100, offset: nil, include: nil) results = db.fetch(sql, params, limit: limit, offset: offset) instances = results.map { |row| from_hash(row) } eager_load(instances, include) if include instances end |
.select_one(sql, params = [], include: nil) ⇒ Object
354 355 356 357 |
# File 'lib/tina4/orm.rb', line 354 def select_one(sql, params = [], include: nil) results = select(sql, params, limit: 1, include: include) results.first end |
.soft_delete ⇒ Object
Soft delete configuration
95 96 97 |
# File 'lib/tina4/orm.rb', line 95 def soft_delete @soft_delete || false end |
.soft_delete=(val) ⇒ Object
99 100 101 |
# File 'lib/tina4/orm.rb', line 99 def soft_delete=(val) @soft_delete = val end |
.soft_delete_field ⇒ Object
103 104 105 |
# File 'lib/tina4/orm.rb', line 103 def soft_delete_field @soft_delete_field || :is_deleted end |
.soft_delete_field=(val) ⇒ Object
107 108 109 |
# File 'lib/tina4/orm.rb', line 107 def soft_delete_field=(val) @soft_delete_field = val end |
.where(conditions, params = [], limit: 100, offset: nil, order_by: nil, include: nil) ⇒ Object
321 322 323 324 325 326 327 328 329 330 331 332 333 |
# File 'lib/tina4/orm.rb', line 321 def where(conditions, params = [], limit: 100, offset: nil, order_by: nil, include: nil) sql = "SELECT * FROM #{table_name}" if soft_delete sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})" else sql += " WHERE #{conditions}" end sql += " ORDER BY #{order_by}" if order_by results = db.fetch(sql, params, limit: limit, offset: offset) instances = results.map { |row| from_hash(row) } eager_load(instances, include) if include instances end |
.with_trashed(conditions = "1=1", params = [], limit: 100, offset: 0) ⇒ Object
436 437 438 439 440 |
# File 'lib/tina4/orm.rb', line 436 def with_trashed(conditions = "1=1", params = [], limit: 100, offset: 0) sql = "SELECT * FROM #{table_name} WHERE #{conditions}" results = db.fetch(sql, params, limit: limit, offset: offset) results.map { |row| from_hash(row) } end |
Instance Method Details
#delete ⇒ Object
Delete this record (soft or hard).
v3.13.39 (bug D): RAISES on a missing primary key, matching #force_delete (which already raised). Previously delete returned false on a nil PK while force_delete raised — an inconsistent contract where "couldn't delete" and "deleted nothing" were indistinguishable on one path but loud on the other. Both now fail loud: deleting a record with no PK is a programmer error, not a quiet no-op. Returns true on a successful delete.
944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 |
# File 'lib/tina4/orm.rb', line 944 def delete pk = self.class.primary_key_field || :id pk_value = __send__(pk) raise "Cannot delete: no primary key value" unless pk_value self.class.db.transaction do |db| if self.class.soft_delete db.update( self.class.table_name, { self.class.soft_delete_field => 1 }, pk_filter ) else db.delete(self.class.table_name, pk_filter) end end @persisted = false true end |
#errors ⇒ Object
1045 1046 1047 |
# File 'lib/tina4/orm.rb', line 1045 def errors @errors end |
#force_delete ⇒ Object
964 965 966 967 968 969 970 971 972 973 974 |
# File 'lib/tina4/orm.rb', line 964 def force_delete pk = self.class.primary_key_field || :id pk_value = __send__(pk) raise "Cannot delete: no primary key value" unless pk_value self.class.db.transaction do |db| db.delete(self.class.table_name, pk_filter) end @persisted = false true end |
#get_error ⇒ Object
Return the cause of the most recent failed #save, or nil.
Mirrors db.get_error. After save returns false — whether from validation
or a driver error — the real cause is retrievable here (and on
#last_error) so a caller using the return false unless model.save
contract can still surface it. Cleared to nil on a successful save.
Cross-framework parity with Python/PHP/Node get_error().
1062 1063 1064 |
# File 'lib/tina4/orm.rb', line 1062 def get_error @last_error end |
#last_error ⇒ Object
Cause of the most recent failed #save (validation message or DB error), or nil when the last save succeeded.
1051 1052 1053 |
# File 'lib/tina4/orm.rb', line 1051 def last_error @last_error end |
#load(arg = nil, params = nil) ⇒ Object
load — populate this instance from the database.
Three forms (parity with Python's model.load(sql, params, include)):
user.load # reload by primary key from instance
user.load(123) # load by primary key value
user.load("email = ?", ["a@b.c"]) # load by filter SQL + params (selectOne)
Returns true on hit, false on miss. Always clears the relationship cache.
1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 |
# File 'lib/tina4/orm.rb', line 1013 def load(arg = nil, params = nil) @relationship_cache = {} # Clear relationship cache on reload pk = self.class.primary_key_field || :id if arg.is_a?(String) # Filter-SQL form: user.load("email = ?", ["a@b.c"]) sql = "SELECT * FROM #{self.class.table_name} WHERE #{arg} LIMIT 1" result = self.class.db.fetch_one(sql, params || []) else # Primary-key form: user.load OR user.load(123) id = arg || __send__(pk) return false unless id result = self.class.db.fetch_one( "SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id] ) end return false unless result mapping_reverse = self.class.field_mapping.invert result.each do |key, value| attr_name = mapping_reverse[key.to_s] || key setter = "#{attr_name}=" __send__(setter, value) if respond_to?(setter) end @persisted = true true end |
#persisted? ⇒ Boolean
1041 1042 1043 |
# File 'lib/tina4/orm.rb', line 1041 def persisted? @persisted end |
#pk_filter ⇒ Object
Insert or update. Returns self on success (fluent), false on failure.
Fails loud, never silent (the same principle db.execute already follows
by raising). On any failure path save returns false — keeping the
contract callers rely on (return false unless model.save) — but it also
(a) logs the real cause via Tina4::Log.error with model/table context and
(b) records the cause on a retrievable per-model error (#last_error /
#get_error, mirroring db.get_error) plus #errors, so a caller can recover
it after the fact. It never raises and never changes the self/false
return shape. On success it returns self (was true pre-v3.13.39 — Ruby
was the sole framework returning a bare boolean here) and clears the
error.
Two distinct failure paths, both loud:
* Validation (v3.13.39): #validate runs FIRST. If it returns errors,
save records them on @errors + @last_error, logs them, and returns
false WITHOUT touching the database — an invalid model never reaches
the driver. (Ruby already enforced validate-on-save; this adds the
loud log + recoverable last_error to the failure path.)
* Database (v3.13.39): a driver error (NOT NULL, duplicate PK, missing
table, …) is rolled back by db.transaction, then captured (db.get_error
falling back to the exception text) onto @last_error, logged with
model/table context, and returns false — the cause is no longer
swallowed silently.
INSERT vs UPDATE (bug B, parity with the Python master): for a NATURAL
(non-auto-increment) primary key that is set, the decision is made on
whether the ROW EXISTS (via self.class.exists), not on @persisted alone.
Pre-v3.13.39 a re-save of a manually-PK'd record that had @persisted set
would UPDATE — but a freshly built (not-yet-persisted) natural-key record
whose row already existed could double-INSERT, or a new-then-save of a
natural key would INSERT then a second save UPDATE a phantom. Probing
existence makes the choice correct regardless of @persisted. Auto-increment
PKs keep the legacy @persisted-based decision (a nil PK means "new row,
let the engine assign an id").
A filter hash naming EVERY primary-key column.
Addressing a row by one column of a composite key matches every row sharing that value. Feature 4 removed that from the raw write path; this is the same rule for the ORM above it.
784 785 786 787 788 |
# File 'lib/tina4/orm.rb', line 784 def pk_filter self.class.primary_key_fields.each_with_object({}) do |name, acc| acc[name] = __send__(name) if respond_to?(name) end end |
#restore ⇒ Object
976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 |
# File 'lib/tina4/orm.rb', line 976 def restore raise "Model does not support soft delete" unless self.class.soft_delete pk = self.class.primary_key_field || :id pk_value = __send__(pk) raise "Cannot restore: no primary key value" unless pk_value self.class.db.transaction do |db| db.update( self.class.table_name, { self.class.soft_delete_field => 0 }, pk_filter ) end __send__("#{self.class.soft_delete_field}=", 0) if respond_to?("#{self.class.soft_delete_field}=") true end |
#save ⇒ Object
790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 |
# File 'lib/tina4/orm.rb', line 790 def save @errors = [] @relationship_cache = {} # Clear relationship cache on save # ── validate() is ENFORCED. An invalid model never reaches the driver — # fail loud (record + log), return false. ── validation_errors = validate unless validation_errors.empty? @errors = validation_errors @last_error = validation_errors.join("; ") Tina4::Log.error( "#{self.class.name}.save refused: validation failed — #{@last_error}" ) return false end pk = self.class.primary_key_field || :id pk_value = __send__(pk) pk_opts = self.class.field_definitions[pk] || {} auto_increment = pk_opts[:auto_increment] # Decide INSERT vs UPDATE. is_update = if pk_value.nil? false elsif auto_increment # Auto-increment: legacy behaviour — a set PK on a persisted instance # means UPDATE. @persisted ? true : false else # Natural key: probe row existence so a re-save never double-inserts # and a first save of a never-seen key still inserts. If the probe # itself fails (e.g. table missing), fall back to INSERT so the caller # sees the real driver error rather than a silent no-op UPDATE. begin # This asked exists(pk_value), which tests only ONE key column. On a # composite key that is true for any row sharing it, so inserting a # genuinely NEW row was decided to be an UPDATE and silently # OVERWROTE a different row: saving (acme, a2) rewrote (acme, a1). # The probe has to name the whole key, like the write that follows. if self.class.primary_key_fields.length > 1 self.class.where( pk_filter.keys.map { |k| "#{k} = ?" }.join(" AND "), pk_filter.values, limit: 1 ).any? else self.class.exists(pk_value) end rescue StandardError false end end begin # The column hashes are built INSIDE this begin (via the transaction # block below) so a JSON column that can't be serialised (to_db_hash / # insert_db_hash raises JSON::GeneratorError) fails loud through the # same path as a driver error — rolled back, false, cause recorded. self.class.db.transaction do |db| if is_update # UPDATE is unchanged (#165 targets INSERT only): keep excluding nil # so a save never nulls a column the caller didn't touch. data = to_db_hash(exclude_nil: true) filter = pk_filter # Never SET a key column - it is what addresses the row. self.class.primary_key_fields.each do |k| data.delete(k) mapped = self.class.field_mapping[k.to_s] data.delete(mapped.to_sym) if mapped end db.update(self.class.table_name, data, filter) else # #165: OMIT a column the caller left unset (value nil, never # assigned) so a NOT NULL DEFAULT column gets its DB default rather # than an explicit NULL; a column the caller set to nil is KEPT and # written as NULL (see #insert_db_hash). insert_data = insert_db_hash if insert_data.empty? # Every insertable column is unset — let the engine apply ALL its # column defaults instead of emitting explicit NULLs. DEFAULT # VALUES is valid on SQLite / PostgreSQL / MSSQL / Firebird; MySQL # spells the all-defaults insert () VALUES (). table = self.class.table_name engine = (self.class.db.respond_to?(:get_database_type) ? self.class.db.get_database_type : "").to_s.downcase db.execute(engine == "mysql" ? "INSERT INTO #{table} () VALUES ()" : "INSERT INTO #{table} DEFAULT VALUES") if auto_increment && respond_to?("#{pk}=") last = db.get_last_id __send__("#{pk}=", last) if last end else result = db.insert(self.class.table_name, insert_data) # Only adopt the engine-assigned id for an auto-increment PK. A # natural-key PK was set by the caller; don't overwrite it with the # driver's last_insert_id (which may be a sequence value that # doesn't apply here). db.insert returns a DatabaseResult (.last_id). if auto_increment && result.last_id && respond_to?("#{pk}=") __send__("#{pk}=", result.last_id) end end end end rescue => e # ── Fail loud, never silent. db.transaction already rolled back and # re-raised. Keep the false return contract, but capture the REAL cause # (prefer db.get_error, which insert/update/execute populate, falling # back to the exception text) on @last_error + @errors so it survives, # and log it with model/table context. ── cause = (self.class.db.get_error rescue nil) || e. # ── DX hint (parity with the Python master's save(), v3.13.60): turn a # bare driver error into an actionable fix for the two commonest ORM # write footguns. Match case-insensitively (SQLite: "no such table" / # "no such column: is_deleted" / "has no column named is_deleted"; # Postgres/MySQL: "does not exist" / "doesn't exist" / "unknown # column"). Any OTHER error keeps its raw cause untouched so an # unrelated failure (NOT NULL, duplicate PK) is never masked. ── low = cause.to_s.downcase sd_field = self.class.soft_delete_field.to_s if self.class.soft_delete && low.include?(sd_field) && ( low.include?("no such column") || low.include?("has no column") || low.include?("does not exist") || low.include?("doesn't exist") || low.include?("unknown column") ) cause += " — soft_delete is on but the '#{sd_field}' column is missing; " \ "declare it (integer_field :#{sd_field}, default: 0) or add a migration" elsif low.include?("no such table") || ( (low.include?("does not exist") || low.include?("doesn't exist")) && !low.include?("column") ) cause += " — table '#{self.class.table_name}' does not exist; " \ "call #{self.class.name}.create_table or run a migration" end @last_error = cause @errors = [cause] Tina4::Log.error( "#{self.class.name}.save failed for table " \ "'#{self.class.table_name}': #{cause}" ) return false end @persisted = true @last_error = nil self end |
#select(*fields) ⇒ Object
1130 1131 1132 1133 1134 1135 |
# File 'lib/tina4/orm.rb', line 1130 def select(*fields) fields_str = fields.map(&:to_s).join(", ") pk = self.class.primary_key_field || :id pk_value = __send__(pk) self.class.db.fetch_one("SELECT #{fields_str} FROM #{self.class.table_name} WHERE #{pk} = ?", [pk_value]) end |
#to_array ⇒ Object Also known as: to_list
1116 1117 1118 |
# File 'lib/tina4/orm.rb', line 1116 def to_array to_h.values end |
#to_h(include: nil, case: nil) ⇒ Object Also known as: to_hash, to_dict, to_object
Convert to hash using Ruby attribute names. Optionally include relationships via the include keyword. case: "camel" converts snake_case keys to camelCase (parity with Python's to_dict(case='camel')). Default keeps native snake_case.
1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 |
# File 'lib/tina4/orm.rb', line 1070 def to_h(include: nil, case: nil) key_case = binding.local_variable_get(:case) # :case is a reserved word hash = {} self.class.field_definitions.each_key do |name| hash[name] = __send__(name) end if include # Group includes: top-level and nested top_level = {} include.each do |inc| parts = inc.to_s.split(".", 2) rel_name = parts[0].to_sym top_level[rel_name] ||= [] top_level[rel_name] << parts[1] if parts.length > 1 end top_level.each do |rel_name, nested| next unless self.class.relationship_definitions.key?(rel_name) = __send__(rel_name) if .nil? hash[rel_name] = nil elsif .is_a?(Array) hash[rel_name] = .map { |r| r.to_h(include: nested.empty? ? nil : nested) } else hash[rel_name] = .to_h(include: nested.empty? ? nil : nested) end end end if key_case == "camel" || key_case == :camel # snake_case → camelCase: split on _, capitalize all but the first hash = hash.each_with_object({}) do |(k, v), out| parts = k.to_s.split("_") camel = parts[0] + parts[1..].map(&:capitalize).join out[camel.to_sym] = v end end hash end |
#to_json(include: nil, **_args) ⇒ Object
1122 1123 1124 |
# File 'lib/tina4/orm.rb', line 1122 def to_json(include: nil, **_args) JSON.generate(to_h(include: include)) end |
#to_s ⇒ Object
1126 1127 1128 |
# File 'lib/tina4/orm.rb', line 1126 def to_s "#<#{self.class.name} #{to_h}>" end |
#validate ⇒ Object
994 995 996 997 998 999 1000 1001 1002 1003 |
# File 'lib/tina4/orm.rb', line 994 def validate errors = [] self.class.field_definitions.each do |name, opts| value = __send__(name) if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default] errors << "#{name} cannot be null" end end errors end |