Class: Tina4::ORM
Class Method Summary collapse
- .all(limit: nil, 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”.
-
.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.
-
.relationship_definitions ⇒ Object
Relationship definitions.
- .scope(name, filter_sql, params = []) ⇒ Object
- .select(sql, params = [], limit: nil, 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 = [], include: nil) ⇒ Object
- .with_trashed(conditions = "1=1", params = [], limit: 20, 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
- #restore ⇒ Object
-
#save ⇒ Object
Insert or update.
- #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.
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 605 606 607 608 609 610 611 612 613 614 615 616 |
# File 'lib/tina4/orm.rb', line 580 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 = {} # 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) __send__("#{name}=", d) end end end |
Class Method Details
.all(limit: nil, offset: nil, order_by: nil, include: nil) ⇒ Object
318 319 320 321 322 323 324 325 326 327 328 |
# File 'lib/tina4/orm.rb', line 318 def all(limit: nil, 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.
134 135 136 |
# File 'lib/tina4/orm.rb', line 134 def auto_crud defined?(@auto_crud) && !@auto_crud.nil? ? @auto_crud : false end |
.auto_crud=(val) ⇒ Object
138 139 140 141 142 143 |
# File 'lib/tina4/orm.rb', line 138 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.
123 124 125 |
# File 'lib/tina4/orm.rb', line 123 def auto_map defined?(@auto_map) && !@auto_map.nil? ? @auto_map : true end |
.auto_map=(val) ⇒ Object
127 128 129 |
# File 'lib/tina4/orm.rb', line 127 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”
182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/tina4/orm.rb', line 182 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 |
.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.
522 523 524 525 |
# File 'lib/tina4/orm.rb', line 522 def clear_rel_cache # -> nil @_rel_cache = {} nil end |
.count(conditions = nil, params = []) ⇒ Object
342 343 344 345 346 347 348 349 350 351 352 |
# File 'lib/tina4/orm.rb', line 342 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.
362 363 364 365 366 |
# File 'lib/tina4/orm.rb', line 362 def create(attributes = {}) instance = new(attributes) return false if instance.save == false instance end |
.create_table ⇒ Object
380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 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 |
# File 'lib/tina4/orm.rb', line 380 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 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: "TEXT" } 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}"] parts << "PRIMARY KEY" if opts[:primary_key] parts << "AUTOINCREMENT" if opts[:auto_increment] parts << "NOT NULL" if !opts[:nullable] && !opts[:primary_key] if opts[:default] && !opts[:auto_increment] parts << "DEFAULT #{default_literal(opts[:default], opts[:type], bool_sql)}" end col_defs << parts.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.
229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 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 |
# File 'lib/tina4/orm.rb', line 229 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.
516 517 518 |
# File 'lib/tina4/orm.rb', line 516 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
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/tina4/orm.rb', line 204 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.
500 501 502 503 504 505 506 507 |
# File 'lib/tina4/orm.rb', line 500 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
368 369 370 371 372 |
# File 'lib/tina4/orm.rb', line 368 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
483 484 485 486 487 488 489 490 491 492 493 494 |
# File 'lib/tina4/orm.rb', line 483 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 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.
528 529 530 |
# File 'lib/tina4/orm.rb', line 528 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.
534 535 536 537 |
# File 'lib/tina4/orm.rb', line 534 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”
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 |
# File 'lib/tina4/orm.rb', line 164 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”
151 152 153 154 155 156 157 158 159 160 161 |
# File 'lib/tina4/orm.rb', line 151 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
200 201 202 |
# File 'lib/tina4/orm.rb', line 200 def query QueryBuilder.from_table(table_name, db: db) end |
.relationship_definitions ⇒ Object
Relationship definitions
146 147 148 |
# File 'lib/tina4/orm.rb', line 146 def relationship_definitions @relationship_definitions ||= {} end |
.scope(name, filter_sql, params = []) ⇒ Object
477 478 479 480 481 |
# File 'lib/tina4/orm.rb', line 477 def scope(name, filter_sql, params = []) define_singleton_method(name) do |limit: 20, offset: 0| where(filter_sql, params) end end |
.select(sql, params = [], limit: nil, offset: nil, include: nil) ⇒ Object
330 331 332 333 334 335 |
# File 'lib/tina4/orm.rb', line 330 def select(sql, params = [], limit: nil, 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
337 338 339 340 |
# File 'lib/tina4/orm.rb', line 337 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 = [], include: nil) ⇒ Object
305 306 307 308 309 310 311 312 313 314 315 316 |
# File 'lib/tina4/orm.rb', line 305 def where(conditions, params = [], 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 results = db.fetch(sql, params) instances = results.map { |row| from_hash(row) } eager_load(instances, include) if include instances end |
.with_trashed(conditions = "1=1", params = [], limit: 20, offset: 0) ⇒ Object
374 375 376 377 378 |
# File 'lib/tina4/orm.rb', line 374 def with_trashed(conditions = "1=1", params = [], limit: 20, 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.
745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 |
# File 'lib/tina4/orm.rb', line 745 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 => pk_value } ) else db.delete(self.class.table_name, { pk => pk_value }) end end @persisted = false true end |
#errors ⇒ Object
846 847 848 |
# File 'lib/tina4/orm.rb', line 846 def errors @errors end |
#force_delete ⇒ Object
765 766 767 768 769 770 771 772 773 774 775 |
# File 'lib/tina4/orm.rb', line 765 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 => pk_value }) 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().
863 864 865 |
# File 'lib/tina4/orm.rb', line 863 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.
852 853 854 |
# File 'lib/tina4/orm.rb', line 852 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.
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 |
# File 'lib/tina4/orm.rb', line 814 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
842 843 844 |
# File 'lib/tina4/orm.rb', line 842 def persisted? @persisted end |
#restore ⇒ Object
777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 |
# File 'lib/tina4/orm.rb', line 777 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 => pk_value } ) end __send__("#{self.class.soft_delete_field}=", 0) if respond_to?("#{self.class.soft_delete_field}=") true end |
#save ⇒ 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”).
654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 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 |
# File 'lib/tina4/orm.rb', line 654 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 data = to_db_hash(exclude_nil: true) 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 self.class.exists(pk_value) rescue StandardError false end end begin self.class.db.transaction do |db| if is_update filter = { pk => pk_value } data.delete(pk) # Remove mapped primary key too mapped_pk = self.class.field_mapping[pk.to_s] data.delete(mapped_pk.to_sym) if mapped_pk db.update(self.class.table_name, data, filter) else result = db.insert(self.class.table_name, 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). if auto_increment && result[:last_id] && respond_to?("#{pk}=") __send__("#{pk}=", result[:last_id]) 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. @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
931 932 933 934 935 936 |
# File 'lib/tina4/orm.rb', line 931 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
917 918 919 |
# File 'lib/tina4/orm.rb', line 917 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.
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 |
# File 'lib/tina4/orm.rb', line 871 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
923 924 925 |
# File 'lib/tina4/orm.rb', line 923 def to_json(include: nil, **_args) JSON.generate(to_h(include: include)) end |
#to_s ⇒ Object
927 928 929 |
# File 'lib/tina4/orm.rb', line 927 def to_s "#<#{self.class.name} #{to_h}>" end |
#validate ⇒ Object
795 796 797 798 799 800 801 802 803 804 |
# File 'lib/tina4/orm.rb', line 795 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 |