Class: Tina4::ORM
Direct Known Subclasses
Realtime::Attachment, Realtime::Channel, Realtime::ChannelMember, Realtime::Message, Realtime::Workspace
Constant Summary collapse
- EAGER_IN_CHUNK =
REL-EAGER-UNBOUNDED: max parent PKs per eager "WHERE fk IN (...)" query, so a very large parent set never yields an unbounded IN list (a query-size / driver parameter-limit risk). Each chunk is one query, each fetched a page at a time so no relation is ever silently truncated.
REL-DEC-01: relationships are READ-SIDE-ONLY -- foreign_key_field wires up traversal accessors but emits NO DB-level FK / ON DELETE clause (consistent with the no-foreign-key Firebird rule), so deleting a parent does not cascade to children at the engine level; integrity is the migration's job.
500- EAGER_PAGE_SIZE =
1000
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".
-
.cache_tags(sql) ⇒ Object
Every table a cached query touches: this model's table plus every FROM/JOIN table in
sql. -
.cached(sql, params = [], ttl: 60, limit: 100, offset: nil, include: nil) ⇒ Object
SQL query with result caching.
-
.clear_cache ⇒ Object
Invalidate every cached query that touches this model's table.
-
.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
-
.tables_in_sql(sql) ⇒ Object
Table names a query reads FROM / JOINs -- lowercased, schema-stripped.
- .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(filter = nil, params = [], include: 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
Validate all declared fields; returns a list of error messages (empty = valid).
Methods included from FieldTypes
Constructor Details
#initialize(attributes = {}) ⇒ ORM
Returns a new instance of ORM.
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 |
# File 'lib/tina4/orm.rb', line 815 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
386 387 388 389 390 391 392 393 394 395 396 |
# File 'lib/tina4/orm.rb', line 386 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.
181 182 183 |
# File 'lib/tina4/orm.rb', line 181 def auto_crud defined?(@auto_crud) && !@auto_crud.nil? ? @auto_crud : false end |
.auto_crud=(val) ⇒ Object
185 186 187 188 189 190 |
# File 'lib/tina4/orm.rb', line 185 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.
170 171 172 |
# File 'lib/tina4/orm.rb', line 170 def auto_map defined?(@auto_map) && !@auto_map.nil? ? @auto_map : true end |
.auto_map=(val) ⇒ Object
174 175 176 |
# File 'lib/tina4/orm.rb', line 174 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"
229 230 231 232 233 234 235 236 237 238 239 |
# File 'lib/tina4/orm.rb', line 229 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 |
.cache_tags(sql) ⇒ Object
Every table a cached query touches: this model's table plus every
FROM/JOIN table in sql. A write to any of these busts the entry.
445 446 447 448 449 |
# File 'lib/tina4/orm.rb', line 445 def (sql) = [table_name.to_s.downcase] tables_in_sql(sql).each { |table| << table unless .include?(table) } 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: same key shape, and a miss
delegates to select so eager loading and the row cap behave
identically to an uncached read.
Invalidation (CACHE-DEC-01): the entry is tagged by every table the query
touches (this model's table plus any FROM/JOIN tables), so a write through
the ORM (save/delete/force_delete/restore) to ANY of those tables busts
it. ttl <= 0 means NO-CACHE -- the query runs and the rows are returned
but nothing is stored, so every read hits the database (it is NOT an
infinite-lived entry).
463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 |
# File 'lib/tina4/orm.rb', line 463 def cached(sql, params = [], ttl: 60, limit: 100, offset: nil, include: nil) # ttl <= 0 is NO-CACHE: run it live, store nothing, read nothing. return select(sql, params, limit: limit, offset: offset, include: include) if ttl <= 0 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: (sql)) result end |
.clear_cache ⇒ Object
Invalidate every cached query that touches this model's table.
Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that reads this table is busted too (it carries this table's tag), while a query that never touches this table is left intact. Called after every ORM write (save/delete/force_delete/restore) so a read-after-write never serves a stale/deleted row (CACHE-DEC-01).
488 489 490 491 |
# File 'lib/tina4/orm.rb', line 488 def clear_cache query_cache.clear_tag(table_name.to_s.downcase) 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.
757 758 759 760 |
# File 'lib/tina4/orm.rb', line 757 def clear_rel_cache # -> nil @_rel_cache = {} nil end |
.count(conditions = nil, params = []) ⇒ Object
493 494 495 496 497 498 499 500 501 502 503 |
# File 'lib/tina4/orm.rb', line 493 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.
513 514 515 516 517 |
# File 'lib/tina4/orm.rb', line 513 def create(attributes = {}) instance = new(attributes) return false if instance.save == false instance end |
.create_table ⇒ Object
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 571 572 573 574 575 576 577 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 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 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 |
# File 'lib/tina4/orm.rb', line 531 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", 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]})" elsif opts[:type] == :decimal # decimal_field stores precision/scale -- emit a real DECIMAL(p, s) # (identical on PG/MySQL/MSSQL/Firebird/SQLite) instead of the old # REAL, which silently DROPPED the declared scale. float_field / # numeric_field stay REAL (the documented float default). precision = opts[:precision] || 10 scale = opts[:scale] || 2 sql_type = "DECIMAL(#{precision},#{scale})" 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 # SOFTDEL-DEC-02: a soft_delete model needs its flag column, but # create_table only knew about DECLARED fields -- so a soft_delete model # that never declared the flag built a table with NO such column, and # every soft-delete read/write then errored on the missing column. Inject # it here (INTEGER 0/1, default 0), honouring the CONFIGURABLE # soft_delete_field (default :is_deleted -- NOT a hard-coded is_deleted), # unless the model already declares it, so the generated schema always # matches the soft-delete behaviour. if soft_delete sd_field = soft_delete_field.to_s declared = field_definitions.keys.map(&:to_s) col_defs << "#{soft_delete_field} INTEGER DEFAULT 0" unless declared.include?(sd_field) 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 # MSSQL and Firebird reject `IF NOT EXISTS` on CREATE TABLE (a syntax # error). The db.table_exists?(table_name) guard at the top of # create_table already returns early when the table is present, so # `IF NOT EXISTS` is pure redundancy on every engine and is simply # omitted where it does not parse. if_not_exists = %w[mssql sqlserver firebird].include?(engine) ? "" : "IF NOT EXISTS " 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) begin db.commit rescue StandardError => ce # execute() auto-commits a standalone DDL. A bare commit then flushes # any implicit transaction, but some drivers (MSSQL/tiny_tds) raise # "COMMIT ... has no corresponding BEGIN TRANSACTION" because there is # no open transaction to commit. The DDL already succeeded, so that # ONE case is benign - re-raise anything else. raise ce unless ce..to_s =~ /no corresponding begin/i end true rescue => e Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error || e.}", { sql: sql }) false end end |
.db ⇒ Object
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/tina4/orm.rb', line 94 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
121 122 123 |
# File 'lib/tina4/orm.rb', line 121 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.
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 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 |
# File 'lib/tina4/orm.rb', line 276 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? # REL-SOFTDELETE-TRAVERSAL: a soft-deleted child must not surface # through eager traversal (parity with lazy + the finders). soft = klass.soft_delete ? " AND (#{klass.soft_delete_field} IS NULL OR #{klass.soft_delete_field} = 0)" : "" order_col = klass.primary_key_field || :id # REL-EAGER-UNBOUNDED: chunk the parent PKs so the IN list stays # bounded, and page each chunk so no relation is truncated. = [] pk_values.each_slice(EAGER_IN_CHUNK) do |chunk| placeholders = chunk.map { "?" }.join(",") sql = "SELECT * FROM #{klass.table_name} WHERE #{fk} IN (#{placeholders})#{soft} ORDER BY #{order_col}" offset = 0 loop do batch = klass.db.fetch(sql, chunk, limit: EAGER_PAGE_SIZE, offset: offset).to_a .concat(batch.map { |row| klass.from_hash(row) }) break if batch.length < EAGER_PAGE_SIZE offset += EAGER_PAGE_SIZE end end # 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 # REL-SOFTDELETE-TRAVERSAL: exclude a soft-deleted parent (parity with # find). REL-EAGER-UNBOUNDED: chunk the FK values. soft = klass.soft_delete ? " AND (#{klass.soft_delete_field} IS NULL OR #{klass.soft_delete_field} = 0)" : "" = [] fk_values.each_slice(EAGER_IN_CHUNK) do |chunk| placeholders = chunk.map { "?" }.join(",") sql = "SELECT * FROM #{klass.table_name} WHERE #{} IN (#{placeholders})#{soft}" # One row per distinct PK, so limit == chunk size (default 100 would truncate a full chunk). .concat(klass.db.fetch(sql, chunk, limit: chunk.length).to_a.map { |row| klass.from_hash(row) }) end 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.
751 752 753 |
# File 'lib/tina4/orm.rb', line 751 def exists(id) !find_by_id(id).nil? end |
.field_mapping ⇒ Object
Field mapping: { 'db_column' => 'ruby_attribute' }
143 144 145 |
# File 'lib/tina4/orm.rb', line 143 def field_mapping @field_mapping || {} end |
.field_mapping=(map) ⇒ Object
147 148 149 |
# File 'lib/tina4/orm.rb', line 147 def field_mapping=(map) @field_mapping = map end |
.find(id_or_filter = nil, filter = nil, **kwargs) ⇒ Object
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 |
# File 'lib/tina4/orm.rb', line 251 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.
735 736 737 738 739 740 741 742 |
# File 'lib/tina4/orm.rb', line 735 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
519 520 521 522 523 |
# File 'lib/tina4/orm.rb', line 519 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
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 |
# File 'lib/tina4/orm.rb', line 703 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.
763 764 765 |
# File 'lib/tina4/orm.rb', line 763 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.
769 770 771 772 |
# File 'lib/tina4/orm.rb', line 769 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"
211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 |
# File 'lib/tina4/orm.rb', line 211 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"
198 199 200 201 202 203 204 205 206 207 208 |
# File 'lib/tina4/orm.rb', line 198 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.
80 81 82 83 84 |
# File 'lib/tina4/orm.rb', line 80 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.
89 90 91 |
# File 'lib/tina4/orm.rb', line 89 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
247 248 249 |
# File 'lib/tina4/orm.rb', line 247 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.
419 420 421 422 |
# File 'lib/tina4/orm.rb', line 419 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
193 194 195 |
# File 'lib/tina4/orm.rb', line 193 def relationship_definitions @relationship_definitions ||= {} end |
.scope(name, filter_sql, params = []) ⇒ Object
697 698 699 700 701 |
# File 'lib/tina4/orm.rb', line 697 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
398 399 400 401 402 403 |
# File 'lib/tina4/orm.rb', line 398 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
405 406 407 408 |
# File 'lib/tina4/orm.rb', line 405 def select_one(sql, params = [], include: nil) results = select(sql, params, limit: 1, include: include) results.first end |
.soft_delete ⇒ Object
Soft delete configuration
126 127 128 |
# File 'lib/tina4/orm.rb', line 126 def soft_delete @soft_delete || false end |
.soft_delete=(val) ⇒ Object
130 131 132 |
# File 'lib/tina4/orm.rb', line 130 def soft_delete=(val) @soft_delete = val end |
.soft_delete_field ⇒ Object
134 135 136 |
# File 'lib/tina4/orm.rb', line 134 def soft_delete_field @soft_delete_field || :is_deleted end |
.soft_delete_field=(val) ⇒ Object
138 139 140 |
# File 'lib/tina4/orm.rb', line 138 def soft_delete_field=(val) @soft_delete_field = val end |
.tables_in_sql(sql) ⇒ Object
Table names a query reads FROM / JOINs -- lowercased, schema-stripped.
Best-effort: for each FROM/JOIN keyword it takes the following identifier, drops any quoting (backticks, double quotes, square brackets) and schema prefix (public.users -> users), and ignores the alias. A cached query is tagged with these tables so a write to any one of them invalidates it (CACHE-DEC-01).
431 432 433 434 435 436 437 438 439 440 441 |
# File 'lib/tina4/orm.rb', line 431 def tables_in_sql(sql) tables = {} (sql || "").scan( %r{\b(?:FROM|JOIN)\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?(?:\.[`"\[]?[A-Za-z_][\w$]*[`"\]]?)?)}i ).each do |match| name = match[0].gsub(/[`"\[\]]/, "") name = name.split(".").last if name.include?(".") tables[name.downcase] = true unless name.empty? end tables.keys end |
.where(conditions, params = [], limit: 100, offset: nil, order_by: nil, include: nil) ⇒ Object
372 373 374 375 376 377 378 379 380 381 382 383 384 |
# File 'lib/tina4/orm.rb', line 372 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
525 526 527 528 529 |
# File 'lib/tina4/orm.rb', line 525 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.
1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 |
# File 'lib/tina4/orm.rb', line 1071 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 # Bust cached reads of any table this write touched (CACHE-DEC-01). self.class.clear_cache true end |
#errors ⇒ Object
1257 1258 1259 |
# File 'lib/tina4/orm.rb', line 1257 def errors @errors end |
#force_delete ⇒ Object
1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 |
# File 'lib/tina4/orm.rb', line 1093 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 # Bust cached reads of any table this write touched (CACHE-DEC-01). self.class.clear_cache 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().
1274 1275 1276 |
# File 'lib/tina4/orm.rb', line 1274 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.
1263 1264 1265 |
# File 'lib/tina4/orm.rb', line 1263 def last_error @last_error end |
#load(filter = nil, params = [], include: nil) ⇒ Object
load — populate this instance from the database.
Signature aligned with Python's model.load(filter, params, include) and PHP's load(?string $filter, array $params, ?array $include) -- LOAD-RUBY-SIGNATURE (feature 26, 3.13.99). filter is nil or a SQL WHERE-fragment String, never a bare scalar.
user.load # reload by primary key from instance
user.load("email = ?", ["a@b.c"]) # load by filter SQL + params
user.load("id = ?", [1], include: [:posts]) # eager-load relations too
BREAKING: the old load(id) scalar-primary-key shortcut is REMOVED -- it
built the malformed fragment "WHERE
LOAD-DEC-01/LOAD-RUBY-ASYMMETRY: hydrates via from_hash -- the SAME coercion every finder uses (a JSON column parses to a native Hash/Array) -- instead of feeding the raw driver row straight to the setters. Before this fix, load() left a JSON column as a raw String while Model.find(id).same_column returned a parsed Hash/Array: the same row, a different type, purely by which read path you called. ONE hydration path now (from_hash), not two.
Returns true on hit, false on miss. Always clears the relationship cache.
1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 |
# File 'lib/tina4/orm.rb', line 1216 def load(filter = nil, params = [], include: nil) if !filter.nil? && !filter.is_a?(String) raise ArgumentError, "#{self.class.name}#load expects a filter String or no argument (got #{filter.inspect}). " \ "The old load(id) primary-key shortcut was removed (LOAD-RUBY-SIGNATURE, 3.13.99) -- " \ "set the primary key attribute then call load with no args, or pass an explicit filter: " \ "load(\"id = ?\", [id])." end @relationship_cache = {} # Clear relationship cache on reload pk = self.class.primary_key_field || :id if filter.nil? # No args — reload by the primary key value already set on this instance id = __send__(pk) return false unless id pk_column = self.class.get_db_column(pk) sql = "SELECT * FROM #{self.class.table_name} WHERE #{pk_column} = ?" result = self.class.db.fetch_one(sql, [id]) else # Filter-SQL form: user.load("email = ?", ["a@b.c"]) sql = "SELECT * FROM #{self.class.table_name} WHERE #{filter} LIMIT 1" result = self.class.db.fetch_one(sql, params) end return false unless result hydrated = self.class.from_hash(result) self.class.field_definitions.each_key do |name| __send__("#{name}=", hydrated.__send__(name)) end self.class.eager_load([self], include) if include @persisted = true true end |
#persisted? ⇒ Boolean
1253 1254 1255 |
# File 'lib/tina4/orm.rb', line 1253 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.
909 910 911 912 913 |
# File 'lib/tina4/orm.rb', line 909 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
1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 |
# File 'lib/tina4/orm.rb', line 1107 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}=") # Bust cached reads of any table this write touched (CACHE-DEC-01). self.class.clear_cache true end |
#save ⇒ Object
915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 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 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 |
# File 'lib/tina4/orm.rb', line 915 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 # Bust cached reads of any table this write touched (CACHE-DEC-01). self.class.clear_cache self end |
#select(*fields) ⇒ Object
1342 1343 1344 1345 1346 1347 |
# File 'lib/tina4/orm.rb', line 1342 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
1328 1329 1330 |
# File 'lib/tina4/orm.rb', line 1328 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.
1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 |
# File 'lib/tina4/orm.rb', line 1282 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
1334 1335 1336 |
# File 'lib/tina4/orm.rb', line 1334 def to_json(include: nil, **_args) JSON.generate(to_h(include: include)) end |
#to_s ⇒ Object
1338 1339 1340 |
# File 'lib/tina4/orm.rb', line 1338 def to_s "#<#{self.class.name} #{to_h}>" end |
#validate ⇒ Object
Validate all declared fields; returns a list of error messages (empty = valid). ENFORCED on save() -- an invalid model never reaches the driver.
Feature 19 (VALID-RUBY-NULLONLY + VALID-TWO-MESSAGES): Ruby's validate used
to be NULL-ONLY, so an over-length or wrong-format value the other three
frameworks reject was written silently. It now enforces the SHARED richness
-- required, string length, numeric range, format (pattern) and numeric type
-- and emits the CANONICAL request-Validator wording ("length: stays a DDL sizing hint and is never validated.
1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 |
# File 'lib/tina4/orm.rb', line 1138 def validate errors = [] self.class.field_definitions.each do |name, opts| value = __send__(name) # required: an explicit required: true fails on nil OR blank (user input); # a NOT NULL column (nullable: false, no default, not auto-increment) # fails on nil (the column constraint). required short-circuits -- no # other rule adds signal on a missing value. blank = value.nil? || (value.is_a?(String) && value.strip.empty?) column_not_null = !opts[:nullable] && !opts[:auto_increment] && !opts[:default] if (opts[:required] && blank) || (column_not_null && value.nil?) errors << "#{name} is required" next end next if value.nil? # length + format apply to string values (a non-string is a type concern). if value.is_a?(String) if opts[:min_length] && value.length < opts[:min_length] errors << "#{name} must be at least #{opts[:min_length]} characters" end if opts[:max_length] && value.length > opts[:max_length] errors << "#{name} must be at most #{opts[:max_length]} characters" end if opts[:pattern] regexp = opts[:pattern].is_a?(Regexp) ? opts[:pattern] : Regexp.new(opts[:pattern]) errors << "#{name} does not match the required format" unless value.match?(regexp) end end # a declared numeric field carrying a non-numeric value is a type error; # a numeric string (a form value like "42") is coerced and range-checked. if %i[integer float decimal].include?(opts[:type]) && !value.is_a?(Numeric) coerced = Float(value, exception: false) if coerced.nil? errors << "#{name} must be a number" next end value = coerced end # numeric range applies to numeric values. if value.is_a?(Numeric) errors << "#{name} must be at least #{opts[:min]}" if opts[:min] && value < opts[:min] errors << "#{name} must be at most #{opts[:max]}" if opts[:max] && value > opts[:max] end end errors end |