tina4ruby 3.13.97 → 3.13.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/lib/tina4/ai.rb +32 -3
  4. data/lib/tina4/api.rb +5 -0
  5. data/lib/tina4/auto_crud.rb +62 -4
  6. data/lib/tina4/background.rb +112 -31
  7. data/lib/tina4/cache.rb +3 -2
  8. data/lib/tina4/cli.rb +55 -67
  9. data/lib/tina4/database.rb +97 -49
  10. data/lib/tina4/database_adapter.rb +169 -15
  11. data/lib/tina4/dev_admin.rb +137 -9
  12. data/lib/tina4/dispatch_pipeline.rb +145 -4
  13. data/lib/tina4/drivers/firebird_driver.rb +59 -12
  14. data/lib/tina4/drivers/mongodb_driver.rb +98 -14
  15. data/lib/tina4/drivers/mssql_driver.rb +39 -2
  16. data/lib/tina4/drivers/mysql_driver.rb +43 -3
  17. data/lib/tina4/drivers/odbc_driver.rb +36 -2
  18. data/lib/tina4/drivers/postgres_driver.rb +5 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +11 -1
  20. data/lib/tina4/env.rb +1 -1
  21. data/lib/tina4/error_overlay.rb +43 -49
  22. data/lib/tina4/field_types.rb +33 -16
  23. data/lib/tina4/frond.rb +24 -2
  24. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
  25. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
  26. data/lib/tina4/graphql.rb +2 -2
  27. data/lib/tina4/log.rb +652 -485
  28. data/lib/tina4/mcp.rb +9 -1
  29. data/lib/tina4/messenger.rb +25 -0
  30. data/lib/tina4/middleware.rb +189 -76
  31. data/lib/tina4/migration.rb +47 -15
  32. data/lib/tina4/orm.rb +280 -59
  33. data/lib/tina4/port_takeover.rb +202 -0
  34. data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
  35. data/lib/tina4/rack_app.rb +201 -59
  36. data/lib/tina4/realtime.rb +6 -1
  37. data/lib/tina4/request.rb +259 -51
  38. data/lib/tina4/router.rb +20 -2
  39. data/lib/tina4/seeder.rb +68 -19
  40. data/lib/tina4/shutdown.rb +4 -0
  41. data/lib/tina4/sql_translator.rb +115 -86
  42. data/lib/tina4/swagger.rb +19 -3
  43. data/lib/tina4/template.rb +61 -6
  44. data/lib/tina4/test_client.rb +49 -3
  45. data/lib/tina4/testing.rb +16 -11
  46. data/lib/tina4/validator.rb +7 -1
  47. data/lib/tina4/version.rb +1 -1
  48. data/lib/tina4/webserver.rb +28 -40
  49. data/lib/tina4.rb +12 -1
  50. metadata +3 -19
  51. data/lib/tina4/scss/tina4css/_alerts.scss +0 -34
  52. data/lib/tina4/scss/tina4css/_badges.scss +0 -22
  53. data/lib/tina4/scss/tina4css/_buttons.scss +0 -69
  54. data/lib/tina4/scss/tina4css/_cards.scss +0 -49
  55. data/lib/tina4/scss/tina4css/_forms.scss +0 -156
  56. data/lib/tina4/scss/tina4css/_grid.scss +0 -81
  57. data/lib/tina4/scss/tina4css/_modals.scss +0 -84
  58. data/lib/tina4/scss/tina4css/_nav.scss +0 -149
  59. data/lib/tina4/scss/tina4css/_pagination.scss +0 -63
  60. data/lib/tina4/scss/tina4css/_reset.scss +0 -94
  61. data/lib/tina4/scss/tina4css/_tables.scss +0 -54
  62. data/lib/tina4/scss/tina4css/_typography.scss +0 -55
  63. data/lib/tina4/scss/tina4css/_utilities.scss +0 -208
  64. data/lib/tina4/scss/tina4css/_variables.scss +0 -117
  65. data/lib/tina4/scss/tina4css/base.scss +0 -1
  66. data/lib/tina4/scss/tina4css/colors.scss +0 -48
  67. data/lib/tina4/scss/tina4css/tina4.scss +0 -18
data/lib/tina4/orm.rb CHANGED
@@ -33,9 +33,40 @@ module Tina4
33
33
  end
34
34
  end
35
35
 
36
+ # Pluralize a singular noun -- the inverse of {singularize}, and the SAME
37
+ # smart-inflection rules the hand-written has_many relies on.
38
+ #
39
+ # "post" -> "posts", "category" -> "categories", "box" -> "boxes",
40
+ # "class" -> "classes", "day" -> "days". foreign_key_field's auto
41
+ # related_name used a naive downcase + "s" ("Category" -> "categorys" -- a
42
+ # broken accessor name); it now routes through here so the FK-wired has_many
43
+ # matches the hand-written one.
44
+ def self.pluralize(word)
45
+ s = word.to_s
46
+ if s =~ /[^aeiou]y\z/i
47
+ s.sub(/y\z/i, "ies")
48
+ elsif s =~ /(s|ss|sh|ch|x|z)\z/i
49
+ "#{s}es"
50
+ else
51
+ "#{s}s"
52
+ end
53
+ end
54
+
36
55
  class ORM
37
56
  include Tina4::FieldTypes
38
57
 
58
+ # REL-EAGER-UNBOUNDED: max parent PKs per eager "WHERE fk IN (...)" query, so
59
+ # a very large parent set never yields an unbounded IN list (a query-size /
60
+ # driver parameter-limit risk). Each chunk is one query, each fetched a page
61
+ # at a time so no relation is ever silently truncated.
62
+ #
63
+ # REL-DEC-01: relationships are READ-SIDE-ONLY -- foreign_key_field wires up
64
+ # traversal accessors but emits NO DB-level FK / ON DELETE clause (consistent
65
+ # with the no-foreign-key Firebird rule), so deleting a parent does not
66
+ # cascade to children at the engine level; integrity is the migration's job.
67
+ EAGER_IN_CHUNK = 500
68
+ EAGER_PAGE_SIZE = 1000
69
+
39
70
  # When a new model class is defined, resolve any deferred ForeignKeyField
40
71
  # wiring that targets it. The string / forward-reference form of
41
72
  # `foreign_key_field` (e.g. `references: "Author"`) records the has_many
@@ -267,10 +298,24 @@ module Tina4
267
298
  pk_values = instances.map { |inst| inst.__send__(pk) }.compact.uniq
268
299
  next if pk_values.empty?
269
300
 
270
- placeholders = pk_values.map { "?" }.join(",")
271
- sql = "SELECT * FROM #{klass.table_name} WHERE #{fk} IN (#{placeholders})"
272
- results = klass.db.fetch(sql, pk_values)
273
- related_records = results.map { |row| klass.from_hash(row) }
301
+ # REL-SOFTDELETE-TRAVERSAL: a soft-deleted child must not surface
302
+ # through eager traversal (parity with lazy + the finders).
303
+ soft = klass.soft_delete ? " AND (#{klass.soft_delete_field} IS NULL OR #{klass.soft_delete_field} = 0)" : ""
304
+ order_col = klass.primary_key_field || :id
305
+ # REL-EAGER-UNBOUNDED: chunk the parent PKs so the IN list stays
306
+ # bounded, and page each chunk so no relation is truncated.
307
+ related_records = []
308
+ pk_values.each_slice(EAGER_IN_CHUNK) do |chunk|
309
+ placeholders = chunk.map { "?" }.join(",")
310
+ sql = "SELECT * FROM #{klass.table_name} WHERE #{fk} IN (#{placeholders})#{soft} ORDER BY #{order_col}"
311
+ offset = 0
312
+ loop do
313
+ batch = klass.db.fetch(sql, chunk, limit: EAGER_PAGE_SIZE, offset: offset).to_a
314
+ related_records.concat(batch.map { |row| klass.from_hash(row) })
315
+ break if batch.length < EAGER_PAGE_SIZE
316
+ offset += EAGER_PAGE_SIZE
317
+ end
318
+ end
274
319
 
275
320
  # Eager load nested
276
321
  klass.eager_load(related_records, nested) unless nested.empty?
@@ -300,10 +345,16 @@ module Tina4
300
345
  next if fk_values.empty?
301
346
 
302
347
  related_pk = klass.primary_key_field || :id
303
- placeholders = fk_values.map { "?" }.join(",")
304
- sql = "SELECT * FROM #{klass.table_name} WHERE #{related_pk} IN (#{placeholders})"
305
- results = klass.db.fetch(sql, fk_values)
306
- related_records = results.map { |row| klass.from_hash(row) }
348
+ # REL-SOFTDELETE-TRAVERSAL: exclude a soft-deleted parent (parity with
349
+ # find). REL-EAGER-UNBOUNDED: chunk the FK values.
350
+ soft = klass.soft_delete ? " AND (#{klass.soft_delete_field} IS NULL OR #{klass.soft_delete_field} = 0)" : ""
351
+ related_records = []
352
+ fk_values.each_slice(EAGER_IN_CHUNK) do |chunk|
353
+ placeholders = chunk.map { "?" }.join(",")
354
+ sql = "SELECT * FROM #{klass.table_name} WHERE #{related_pk} IN (#{placeholders})#{soft}"
355
+ # One row per distinct PK, so limit == chunk size (default 100 would truncate a full chunk).
356
+ related_records.concat(klass.db.fetch(sql, chunk, limit: chunk.length).to_a.map { |row| klass.from_hash(row) })
357
+ end
307
358
 
308
359
  klass.eager_load(related_records, nested) unless nested.empty?
309
360
 
@@ -370,15 +421,49 @@ module Tina4
370
421
  ORM.instance_variable_set(:@query_cache, QueryCache.new(default_ttl: 0, max_size: 500))
371
422
  end
372
423
 
424
+ # Table names a query reads FROM / JOINs -- lowercased, schema-stripped.
425
+ #
426
+ # Best-effort: for each FROM/JOIN keyword it takes the following
427
+ # identifier, drops any quoting (backticks, double quotes, square
428
+ # brackets) and schema prefix (public.users -> users), and ignores the
429
+ # alias. A cached query is tagged with these tables so a write to any one
430
+ # of them invalidates it (CACHE-DEC-01).
431
+ def tables_in_sql(sql)
432
+ tables = {}
433
+ (sql || "").scan(
434
+ %r{\b(?:FROM|JOIN)\s+([`"\[]?[A-Za-z_][\w$]*[`"\]]?(?:\.[`"\[]?[A-Za-z_][\w$]*[`"\]]?)?)}i
435
+ ).each do |match|
436
+ name = match[0].gsub(/[`"\[\]]/, "")
437
+ name = name.split(".").last if name.include?(".")
438
+ tables[name.downcase] = true unless name.empty?
439
+ end
440
+ tables.keys
441
+ end
442
+
443
+ # Every table a cached query touches: this model's table plus every
444
+ # FROM/JOIN table in `sql`. A write to any of these busts the entry.
445
+ def cache_tags(sql)
446
+ tags = [table_name.to_s.downcase]
447
+ tables_in_sql(sql).each { |table| tags << table unless tags.include?(table) }
448
+ tags
449
+ end
450
+
373
451
  # SQL query with result caching. Returns an array of ORM instances.
374
452
  #
375
- # Parity with the Python master's `cached` (orm/model.py:1077): same key
376
- # shape, same tag, and a miss delegates to `select` so eager loading and the
377
- # row cap behave identically to an uncached read.
453
+ # Parity with the Python master's `cached`: same key shape, and a miss
454
+ # delegates to `select` so eager loading and the row cap behave
455
+ # identically to an uncached read.
378
456
  #
379
- # Entries are tagged with the model name so #clear_cache invalidates only
380
- # this model's queries and leaves every other model's cached reads intact.
457
+ # Invalidation (CACHE-DEC-01): the entry is tagged by every table the query
458
+ # touches (this model's table plus any FROM/JOIN tables), so a write through
459
+ # the ORM (save/delete/force_delete/restore) to ANY of those tables busts
460
+ # it. `ttl <= 0` means NO-CACHE -- the query runs and the rows are returned
461
+ # but nothing is stored, so every read hits the database (it is NOT an
462
+ # infinite-lived entry).
381
463
  def cached(sql, params = [], ttl: 60, limit: 100, offset: nil, include: nil)
464
+ # ttl <= 0 is NO-CACHE: run it live, store nothing, read nothing.
465
+ return select(sql, params, limit: limit, offset: offset, include: include) if ttl <= 0
466
+
382
467
  key = "#{name}:#{QueryCache.query_key(sql, params)}:#{limit}:#{offset || 0}"
383
468
 
384
469
  # nil-check, NOT a truthiness check: a query that legitimately returns no
@@ -389,15 +474,19 @@ module Tina4
389
474
  return hit unless hit.nil?
390
475
 
391
476
  result = select(sql, params, limit: limit, offset: offset, include: include)
392
- query_cache.set(key, result, ttl: ttl, tags: [name])
477
+ query_cache.set(key, result, ttl: ttl, tags: cache_tags(sql))
393
478
  result
394
479
  end
395
480
 
396
- # Invalidate every cached query for THIS model. Tag-scoped, so it never
397
- # flushes another model's entries. Mirrors the master's
398
- # `_query_cache.clear_tag(cls.__name__)`.
481
+ # Invalidate every cached query that touches this model's table.
482
+ #
483
+ # Tag-scoped, NOT a wholesale flush: a cached JOIN on another model that
484
+ # reads this table is busted too (it carries this table's tag), while a
485
+ # query that never touches this table is left intact. Called after every
486
+ # ORM write (save/delete/force_delete/restore) so a read-after-write never
487
+ # serves a stale/deleted row (CACHE-DEC-01).
399
488
  def clear_cache
400
- query_cache.clear_tag(name)
489
+ query_cache.clear_tag(table_name.to_s.downcase)
401
490
  nil
402
491
  end
403
492
 
@@ -500,7 +589,6 @@ module Tina4
500
589
  string: "VARCHAR(255)",
501
590
  text: "TEXT",
502
591
  float: "REAL",
503
- decimal: "REAL",
504
592
  boolean: bool_sql,
505
593
  date: "DATE",
506
594
  datetime: datetime_sql,
@@ -514,6 +602,14 @@ module Tina4
514
602
  sql_type = type_map[opts[:type]] || "TEXT"
515
603
  if opts[:type] == :string && opts[:length]
516
604
  sql_type = "VARCHAR(#{opts[:length]})"
605
+ elsif opts[:type] == :decimal
606
+ # decimal_field stores precision/scale -- emit a real DECIMAL(p, s)
607
+ # (identical on PG/MySQL/MSSQL/Firebird/SQLite) instead of the old
608
+ # REAL, which silently DROPPED the declared scale. float_field /
609
+ # numeric_field stay REAL (the documented float default).
610
+ precision = opts[:precision] || 10
611
+ scale = opts[:scale] || 2
612
+ sql_type = "DECIMAL(#{precision},#{scale})"
517
613
  end
518
614
 
519
615
  parts = ["#{name} #{sql_type}"]
@@ -539,13 +635,33 @@ module Tina4
539
635
  col_defs << parts.join(" ")
540
636
  end
541
637
 
638
+ # SOFTDEL-DEC-02: a soft_delete model needs its flag column, but
639
+ # create_table only knew about DECLARED fields -- so a soft_delete model
640
+ # that never declared the flag built a table with NO such column, and
641
+ # every soft-delete read/write then errored on the missing column. Inject
642
+ # it here (INTEGER 0/1, default 0), honouring the CONFIGURABLE
643
+ # soft_delete_field (default :is_deleted -- NOT a hard-coded is_deleted),
644
+ # unless the model already declares it, so the generated schema always
645
+ # matches the soft-delete behaviour.
646
+ if soft_delete
647
+ sd_field = soft_delete_field.to_s
648
+ declared = field_definitions.keys.map(&:to_s)
649
+ col_defs << "#{soft_delete_field} INTEGER DEFAULT 0" unless declared.include?(sd_field)
650
+ end
651
+
542
652
  # A COMPOSITE key is declared ONCE, at table level; the per-column inline
543
653
  # form above is suppressed for it.
544
654
  if primary_key_fields.length > 1
545
655
  col_defs << "PRIMARY KEY (#{primary_key_fields.join(', ')})"
546
656
  end
547
657
 
548
- sql = "CREATE TABLE IF NOT EXISTS #{table_name} (#{col_defs.join(', ')})"
658
+ # MSSQL and Firebird reject `IF NOT EXISTS` on CREATE TABLE (a syntax
659
+ # error). The db.table_exists?(table_name) guard at the top of
660
+ # create_table already returns early when the table is present, so
661
+ # `IF NOT EXISTS` is pure redundancy on every engine and is simply
662
+ # omitted where it does not parse.
663
+ if_not_exists = %w[mssql sqlserver firebird].include?(engine) ? "" : "IF NOT EXISTS "
664
+ sql = "CREATE TABLE #{if_not_exists}#{table_name} (#{col_defs.join(', ')})"
549
665
 
550
666
  # Translate AUTOINCREMENT to the engine's auto-increment syntax
551
667
  # (INTEGER PRIMARY KEY AUTOINCREMENT -> SERIAL PRIMARY KEY on PG, etc.).
@@ -561,7 +677,16 @@ module Tina4
561
677
  # still see a clean failure instead of a thrown error.
562
678
  begin
563
679
  db.execute(sql)
564
- db.commit
680
+ begin
681
+ db.commit
682
+ rescue StandardError => ce
683
+ # execute() auto-commits a standalone DDL. A bare commit then flushes
684
+ # any implicit transaction, but some drivers (MSSQL/tiny_tds) raise
685
+ # "COMMIT ... has no corresponding BEGIN TRANSACTION" because there is
686
+ # no open transaction to commit. The DDL already succeeded, so that
687
+ # ONE case is benign - re-raise anything else.
688
+ raise ce unless ce.message.to_s =~ /no corresponding begin/i
689
+ end
565
690
  true
566
691
  rescue => e
567
692
  Tina4::Log.error("create_table failed for #{table_name}: #{db.get_error || e.message}", { sql: sql })
@@ -930,6 +1055,8 @@ module Tina4
930
1055
 
931
1056
  @persisted = true
932
1057
  @last_error = nil
1058
+ # Bust cached reads of any table this write touched (CACHE-DEC-01).
1059
+ self.class.clear_cache
933
1060
  self
934
1061
  end
935
1062
 
@@ -958,6 +1085,8 @@ module Tina4
958
1085
  end
959
1086
  end
960
1087
  @persisted = false
1088
+ # Bust cached reads of any table this write touched (CACHE-DEC-01).
1089
+ self.class.clear_cache
961
1090
  true
962
1091
  end
963
1092
 
@@ -970,6 +1099,8 @@ module Tina4
970
1099
  db.delete(self.class.table_name, pk_filter)
971
1100
  end
972
1101
  @persisted = false
1102
+ # Bust cached reads of any table this write touched (CACHE-DEC-01).
1103
+ self.class.clear_cache
973
1104
  true
974
1105
  end
975
1106
 
@@ -988,15 +1119,68 @@ module Tina4
988
1119
  )
989
1120
  end
990
1121
  __send__("#{self.class.soft_delete_field}=", 0) if respond_to?("#{self.class.soft_delete_field}=")
1122
+ # Bust cached reads of any table this write touched (CACHE-DEC-01).
1123
+ self.class.clear_cache
991
1124
  true
992
1125
  end
993
1126
 
1127
+ # Validate all declared fields; returns a list of error messages (empty =
1128
+ # valid). ENFORCED on save() -- an invalid model never reaches the driver.
1129
+ #
1130
+ # Feature 19 (VALID-RUBY-NULLONLY + VALID-TWO-MESSAGES): Ruby's validate used
1131
+ # to be NULL-ONLY, so an over-length or wrong-format value the other three
1132
+ # frameworks reject was written silently. It now enforces the SHARED richness
1133
+ # -- required, string length, numeric range, format (pattern) and numeric type
1134
+ # -- and emits the CANONICAL request-Validator wording ("<field> is required",
1135
+ # "<field> must be at most N characters", "<field> does not match the required
1136
+ # format") so the ORM validator and the request-body Validator speak ONE
1137
+ # vocabulary. `length:` stays a DDL sizing hint and is never validated.
994
1138
  def validate
995
1139
  errors = []
996
1140
  self.class.field_definitions.each do |name, opts|
997
1141
  value = __send__(name)
998
- if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default]
999
- errors << "#{name} cannot be null"
1142
+
1143
+ # required: an explicit required: true fails on nil OR blank (user input);
1144
+ # a NOT NULL column (nullable: false, no default, not auto-increment)
1145
+ # fails on nil (the column constraint). required short-circuits -- no
1146
+ # other rule adds signal on a missing value.
1147
+ blank = value.nil? || (value.is_a?(String) && value.strip.empty?)
1148
+ column_not_null = !opts[:nullable] && !opts[:auto_increment] && !opts[:default]
1149
+ if (opts[:required] && blank) || (column_not_null && value.nil?)
1150
+ errors << "#{name} is required"
1151
+ next
1152
+ end
1153
+ next if value.nil?
1154
+
1155
+ # length + format apply to string values (a non-string is a type concern).
1156
+ if value.is_a?(String)
1157
+ if opts[:min_length] && value.length < opts[:min_length]
1158
+ errors << "#{name} must be at least #{opts[:min_length]} characters"
1159
+ end
1160
+ if opts[:max_length] && value.length > opts[:max_length]
1161
+ errors << "#{name} must be at most #{opts[:max_length]} characters"
1162
+ end
1163
+ if opts[:pattern]
1164
+ regexp = opts[:pattern].is_a?(Regexp) ? opts[:pattern] : Regexp.new(opts[:pattern])
1165
+ errors << "#{name} does not match the required format" unless value.match?(regexp)
1166
+ end
1167
+ end
1168
+
1169
+ # a declared numeric field carrying a non-numeric value is a type error;
1170
+ # a numeric string (a form value like "42") is coerced and range-checked.
1171
+ if %i[integer float decimal].include?(opts[:type]) && !value.is_a?(Numeric)
1172
+ coerced = Float(value, exception: false)
1173
+ if coerced.nil?
1174
+ errors << "#{name} must be a number"
1175
+ next
1176
+ end
1177
+ value = coerced
1178
+ end
1179
+
1180
+ # numeric range applies to numeric values.
1181
+ if value.is_a?(Numeric)
1182
+ errors << "#{name} must be at least #{opts[:min]}" if opts[:min] && value < opts[:min]
1183
+ errors << "#{name} must be at most #{opts[:max]}" if opts[:max] && value > opts[:max]
1000
1184
  end
1001
1185
  end
1002
1186
  errors
@@ -1004,36 +1188,64 @@ module Tina4
1004
1188
 
1005
1189
  # load — populate this instance from the database.
1006
1190
  #
1007
- # Three forms (parity with Python's model.load(sql, params, include)):
1008
- # user.load # reload by primary key from instance
1009
- # user.load(123) # load by primary key value
1010
- # user.load("email = ?", ["a@b.c"]) # load by filter SQL + params (selectOne)
1191
+ # Signature aligned with Python's model.load(filter, params, include) and
1192
+ # PHP's load(?string $filter, array $params, ?array $include) --
1193
+ # LOAD-RUBY-SIGNATURE (feature 26, 3.13.99). filter is nil or a SQL
1194
+ # WHERE-fragment String, never a bare scalar.
1195
+ #
1196
+ # user.load # reload by primary key from instance
1197
+ # user.load("email = ?", ["a@b.c"]) # load by filter SQL + params
1198
+ # user.load("id = ?", [1], include: [:posts]) # eager-load relations too
1199
+ #
1200
+ # BREAKING: the old load(id) scalar-primary-key shortcut is REMOVED -- it
1201
+ # built the malformed fragment "WHERE <id>" for any other filter argument
1202
+ # style, which is valid-but-wrong SQL (a constant truthy WHERE matches the
1203
+ # table's first row, not the requested id) rather than a clean error. Set
1204
+ # the primary key attribute then call load with no args, or pass an
1205
+ # explicit "pk = ?" filter.
1206
+ #
1207
+ # LOAD-DEC-01/LOAD-RUBY-ASYMMETRY: hydrates via from_hash -- the SAME
1208
+ # coercion every finder uses (a JSON column parses to a native Hash/Array)
1209
+ # -- instead of feeding the raw driver row straight to the setters. Before
1210
+ # this fix, load() left a JSON column as a raw String while
1211
+ # Model.find(id).same_column returned a parsed Hash/Array: the same row,
1212
+ # a different type, purely by which read path you called. ONE hydration
1213
+ # path now (from_hash), not two.
1011
1214
  #
1012
1215
  # Returns true on hit, false on miss. Always clears the relationship cache.
1013
- def load(arg = nil, params = nil)
1216
+ def load(filter = nil, params = [], include: nil)
1217
+ if !filter.nil? && !filter.is_a?(String)
1218
+ raise ArgumentError,
1219
+ "#{self.class.name}#load expects a filter String or no argument (got #{filter.inspect}). " \
1220
+ "The old load(id) primary-key shortcut was removed (LOAD-RUBY-SIGNATURE, 3.13.99) -- " \
1221
+ "set the primary key attribute then call load with no args, or pass an explicit filter: " \
1222
+ "load(\"id = ?\", [id])."
1223
+ end
1224
+
1014
1225
  @relationship_cache = {} # Clear relationship cache on reload
1015
1226
  pk = self.class.primary_key_field || :id
1016
1227
 
1017
- if arg.is_a?(String)
1018
- # Filter-SQL form: user.load("email = ?", ["a@b.c"])
1019
- sql = "SELECT * FROM #{self.class.table_name} WHERE #{arg} LIMIT 1"
1020
- result = self.class.db.fetch_one(sql, params || [])
1021
- else
1022
- # Primary-key form: user.load OR user.load(123)
1023
- id = arg || __send__(pk)
1228
+ if filter.nil?
1229
+ # No args reload by the primary key value already set on this instance
1230
+ id = __send__(pk)
1024
1231
  return false unless id
1025
- result = self.class.db.fetch_one(
1026
- "SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
1027
- )
1232
+ pk_column = self.class.get_db_column(pk)
1233
+ sql = "SELECT * FROM #{self.class.table_name} WHERE #{pk_column} = ?"
1234
+ result = self.class.db.fetch_one(sql, [id])
1235
+ else
1236
+ # Filter-SQL form: user.load("email = ?", ["a@b.c"])
1237
+ sql = "SELECT * FROM #{self.class.table_name} WHERE #{filter} LIMIT 1"
1238
+ result = self.class.db.fetch_one(sql, params)
1028
1239
  end
1029
1240
  return false unless result
1030
1241
 
1031
- mapping_reverse = self.class.field_mapping.invert
1032
- result.each do |key, value|
1033
- attr_name = mapping_reverse[key.to_s] || key
1034
- setter = "#{attr_name}="
1035
- __send__(setter, value) if respond_to?(setter)
1242
+ hydrated = self.class.from_hash(result)
1243
+ self.class.field_definitions.each_key do |name|
1244
+ __send__("#{name}=", hydrated.__send__(name))
1036
1245
  end
1246
+
1247
+ self.class.eager_load([self], include) if include
1248
+
1037
1249
  @persisted = true
1038
1250
  true
1039
1251
  end
@@ -1194,15 +1406,6 @@ module Tina4
1194
1406
  hash
1195
1407
  end
1196
1408
 
1197
- def validate_fields
1198
- self.class.field_definitions.each do |name, opts|
1199
- value = __send__(name)
1200
- if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default]
1201
- @errors << "#{name} cannot be null"
1202
- end
1203
- end
1204
- end
1205
-
1206
1409
  def load_has_one(name)
1207
1410
  return @relationship_cache[name] if @relationship_cache.key?(name)
1208
1411
  rel = self.class.relationship_definitions[name]
@@ -1214,9 +1417,12 @@ module Tina4
1214
1417
  pk_value = __send__(pk)
1215
1418
  return nil unless pk_value
1216
1419
 
1217
- result = klass.db.fetch_one(
1218
- "SELECT * FROM #{klass.table_name} WHERE #{fk} = ?", [pk_value]
1219
- )
1420
+ where = "#{fk} = ?"
1421
+ # REL-SOFTDELETE-TRAVERSAL: exclude a soft-deleted related row.
1422
+ if klass.soft_delete
1423
+ where += " AND (#{klass.soft_delete_field} IS NULL OR #{klass.soft_delete_field} = 0)"
1424
+ end
1425
+ result = klass.db.fetch_one("SELECT * FROM #{klass.table_name} WHERE #{where}", [pk_value])
1220
1426
  @relationship_cache[name] = result ? klass.from_hash(result) : nil
1221
1427
  end
1222
1428
 
@@ -1231,10 +1437,25 @@ module Tina4
1231
1437
  pk_value = __send__(pk)
1232
1438
  return [] unless pk_value
1233
1439
 
1234
- results = klass.db.fetch(
1235
- "SELECT * FROM #{klass.table_name} WHERE #{fk} = ?", [pk_value]
1236
- )
1237
- @relationship_cache[name] = results.map { |row| klass.from_hash(row) }
1440
+ where = "#{fk} = ?"
1441
+ # REL-SOFTDELETE-TRAVERSAL: a soft-deleted child must not surface through
1442
+ # parent.children, consistent with the finders' default exclusion.
1443
+ if klass.soft_delete
1444
+ where += " AND (#{klass.soft_delete_field} IS NULL OR #{klass.soft_delete_field} = 0)"
1445
+ end
1446
+ order_col = klass.primary_key_field || :id
1447
+ sql = "SELECT * FROM #{klass.table_name} WHERE #{where} ORDER BY #{order_col}"
1448
+ # REL-EAGER-UNBOUNDED: page through ALL children rather than silently capping
1449
+ # at the default fetch limit (was 100), so the tail is never lost.
1450
+ records = []
1451
+ offset = 0
1452
+ loop do
1453
+ batch = klass.db.fetch(sql, [pk_value], limit: EAGER_PAGE_SIZE, offset: offset).to_a
1454
+ records.concat(batch)
1455
+ break if batch.length < EAGER_PAGE_SIZE
1456
+ offset += EAGER_PAGE_SIZE
1457
+ end
1458
+ @relationship_cache[name] = records.map { |row| klass.from_hash(row) }
1238
1459
  end
1239
1460
 
1240
1461
  def load_belongs_to(name)