tina4ruby 3.11.32 → 3.11.36

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1d2fabb4883ebcdd387f24cfc0c3e858304d7498b1439798b448c89623fa56e
4
- data.tar.gz: 592c8eac568eba0950bde87ac4fd9b7d9bfed365aa23539b2f50409ff6a1f1d9
3
+ metadata.gz: 9615f251d5bed0d97024c8e061bdf34d26afb2677e5c6ec989005d45d3edc6c9
4
+ data.tar.gz: 7ef7b684a87906e932f3c929cfe57596f67bf81bf63cf35e79b8d343f2ab297a
5
5
  SHA512:
6
- metadata.gz: '0917b8cbeaaa155cbf223bcb009662bed7f48e03e1b9b4d96c338a17fdf2cbac4333d88c549980d650a70eb515532b83783090cb4f3896ca8f7f53fba8307347'
7
- data.tar.gz: c9b3da407eb7823e9e7b19af1d3928329d061b5a8d77bd904d96870ea89e90d9dbd0979b3909bff66011c584e556bb2c3dfcf219a078e6ffd28d2ac75cea9a7e
6
+ metadata.gz: ecf726e25d06e8ecbed12874fa4f36edf20286ea10c2c9b6c973a2d9d430dd38933a95240b8d50dd4237b82e076b634527e0c28016aa6e94f6f71a7747d126f8
7
+ data.tar.gz: bc0603a6370d2dc27f26ad5cee86c00f233213d57dc904075f70c5befdf28ff81b78d0c25ffa25a42f34ae9cf48d0a33a322e1147d12bc05db1f125db245538f
@@ -5,6 +5,20 @@ module Tina4
5
5
  class FirebirdDriver
6
6
  attr_reader :connection
7
7
 
8
+ # Substring markers (lowercased) that identify a dead-socket Firebird
9
+ # error worth reconnecting for. Idle Firebird connections die silently
10
+ # behind NAT timeouts, server-side ConnectionIdleTimeout, or Docker
11
+ # network rotation; without this the next prepare crashes the request.
12
+ DEAD_CONN_MARKERS = [
13
+ "error writing data to the connection",
14
+ "error reading data from the connection",
15
+ "connection shutdown",
16
+ "connection lost",
17
+ "network error",
18
+ "connection is not active",
19
+ "broken pipe"
20
+ ].freeze
21
+
8
22
  def connect(connection_string, username: nil, password: nil)
9
23
  require "fb"
10
24
  require "uri"
@@ -21,10 +35,13 @@ module Tina4
21
35
  db_path || connection_string.sub(/^firebird:\/\//, "")
22
36
  end
23
37
 
24
- opts = { database: database }
25
- opts[:username] = db_user if db_user
26
- opts[:password] = db_pass if db_pass
27
- @connection = Fb::Database.new(**opts).connect
38
+ # Cache for transparent reconnect — never logged, lives only in
39
+ # driver memory alongside the connection it owns.
40
+ @connect_opts = { database: database }
41
+ @connect_opts[:username] = db_user if db_user
42
+ @connect_opts[:password] = db_pass if db_pass
43
+
44
+ open_connection
28
45
  rescue LoadError
29
46
  raise "Firebird driver requires the 'fb' gem. Install it with: gem install fb"
30
47
  end
@@ -34,22 +51,35 @@ module Tina4
34
51
  end
35
52
 
36
53
  def execute_query(sql, params = [])
37
- rows = if params.empty?
38
- @connection.query(:hash, sql)
39
- else
40
- @connection.query(:hash, sql, *params)
41
- end
54
+ rows = with_reconnect do
55
+ if params.empty?
56
+ @connection.query(:hash, sql)
57
+ else
58
+ @connection.query(:hash, sql, *params)
59
+ end
60
+ end
42
61
  rows.map { |row| decode_blobs(stringify_keys(row)) }
43
62
  end
44
63
 
45
64
  def execute(sql, params = [])
46
- if params.empty?
47
- @connection.execute(sql)
48
- else
49
- @connection.execute(sql, *params)
65
+ with_reconnect do
66
+ if params.empty?
67
+ @connection.execute(sql)
68
+ else
69
+ @connection.execute(sql, *params)
70
+ end
50
71
  end
51
72
  end
52
73
 
74
+ # Public so specs (and curious operators) can verify the matcher
75
+ # behaviour without poking private methods.
76
+ def self.dead_connection?(error_or_message)
77
+ msg = error_or_message.respond_to?(:message) ? error_or_message.message : error_or_message.to_s
78
+ return false if msg.nil? || msg.empty?
79
+ lower = msg.downcase
80
+ DEAD_CONN_MARKERS.any? { |m| lower.include?(m) }
81
+ end
82
+
53
83
  def last_insert_id
54
84
  nil
55
85
  end
@@ -103,6 +133,34 @@ module Tina4
103
133
 
104
134
  private
105
135
 
136
+ def open_connection
137
+ @connection = Fb::Database.new(**@connect_opts).connect
138
+ end
139
+
140
+ # Force-close a stale handle and reopen using cached opts. Idempotent —
141
+ # safe to call when the connection is already gone.
142
+ def reconnect!
143
+ begin
144
+ @connection&.close
145
+ rescue StandardError
146
+ # connection already gone — nothing to clean up
147
+ end
148
+ @connection = nil
149
+ @transaction = nil
150
+ open_connection
151
+ end
152
+
153
+ # Run a block; if it raises with a dead-connection signature, reconnect
154
+ # once and retry. Skipped inside an explicit transaction — atomicity
155
+ # beats resilience there; the caller handles rollback.
156
+ def with_reconnect
157
+ yield
158
+ rescue StandardError => e
159
+ raise unless self.class.dead_connection?(e) && @transaction.nil?
160
+ reconnect!
161
+ yield
162
+ end
163
+
106
164
  def stringify_keys(hash)
107
165
  hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
108
166
  end
data/lib/tina4/orm.rb CHANGED
@@ -4,7 +4,7 @@ require "json"
4
4
  module Tina4
5
5
  # Convert a snake_case name to camelCase.
6
6
  def self.snake_to_camel(name)
7
- parts = name.to_s.downcase.split("_")
7
+ parts = name.to_s.split("_")
8
8
  parts[0] + parts[1..].map(&:capitalize).join
9
9
  end
10
10
 
@@ -18,7 +18,7 @@ module Tina4
18
18
 
19
19
  class << self
20
20
  def db
21
- @db || Tina4.database || auto_discover_db
21
+ @db || Tina4.database
22
22
  end
23
23
 
24
24
  # Per-model database binding
@@ -52,24 +52,28 @@ module Tina4
52
52
  @field_mapping = map
53
53
  end
54
54
 
55
- # Auto-map flag (no-op in Ruby since snake_case is native)
55
+ # Auto-map flag defaults to TRUE for cross-framework parity (Python's
56
+ # ORM has auto_map=True by default). The instance variable is treated
57
+ # as "unset" when nil; only an explicit `false` disables it.
56
58
  def auto_map
57
- @auto_map.nil? ? true : @auto_map
59
+ defined?(@auto_map) && !@auto_map.nil? ? @auto_map : true
58
60
  end
59
61
 
60
62
  def auto_map=(val)
61
63
  @auto_map = val
62
64
  end
63
65
 
64
- # Auto-CRUD flag: when set to true, registers this model for CRUD route generation
66
+ # auto_crud flag when set to true, the class registers itself with
67
+ # Tina4::AutoCrud which auto-generates REST endpoints from the model.
68
+ # Defaults to false. Cross-framework parity with Python's autoCrud.
65
69
  def auto_crud
66
- @auto_crud || false
70
+ defined?(@auto_crud) && !@auto_crud.nil? ? @auto_crud : false
67
71
  end
68
72
 
69
73
  def auto_crud=(val)
70
74
  @auto_crud = val
71
- if val
72
- Tina4::AutoCrud.register(self) if defined?(Tina4::AutoCrud)
75
+ if val && defined?(::Tina4::AutoCrud)
76
+ ::Tina4::AutoCrud.models << self unless ::Tina4::AutoCrud.models.include?(self)
73
77
  end
74
78
  end
75
79
 
@@ -79,7 +83,7 @@ module Tina4
79
83
  end
80
84
 
81
85
  # has_one :profile, class_name: "Profile", foreign_key: "user_id"
82
- def has_one(name, class_name: nil, foreign_key: nil) # -> nil
86
+ def has_one(name, class_name: nil, foreign_key: nil)
83
87
  relationship_definitions[name] = {
84
88
  type: :has_one,
85
89
  class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
@@ -92,7 +96,7 @@ module Tina4
92
96
  end
93
97
 
94
98
  # has_many :posts, class_name: "Post", foreign_key: "user_id"
95
- def has_many(name, class_name: nil, foreign_key: nil) # -> nil
99
+ def has_many(name, class_name: nil, foreign_key: nil)
96
100
  relationship_definitions[name] = {
97
101
  type: :has_many,
98
102
  class_name: class_name || name.to_s.sub(/s$/, "").split("_").map(&:capitalize).join,
@@ -105,7 +109,7 @@ module Tina4
105
109
  end
106
110
 
107
111
  # belongs_to :user, class_name: "User", foreign_key: "user_id"
108
- def belongs_to(name, class_name: nil, foreign_key: nil) # -> nil
112
+ def belongs_to(name, class_name: nil, foreign_key: nil)
109
113
  relationship_definitions[name] = {
110
114
  type: :belongs_to,
111
115
  class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
@@ -123,46 +127,31 @@ module Tina4
123
127
  # results = User.query.where("active = ?", [1]).order_by("name").get
124
128
  #
125
129
  # @return [Tina4::QueryBuilder]
126
- def query # -> QueryBuilder
127
- QueryBuilder.from_table(table_name, db: db)
128
- end
129
-
130
- # Find records by filter dict. Always returns an array.
131
- #
132
- # Usage:
133
- # User.find(name: "Alice") [User, ...]
134
- # User.find({age: 18}, limit: 10) → [User, ...]
135
- # User.find(order_by: "name ASC") [User, ...]
136
- # User.find → all records
137
- #
138
- # Use find_by_id(id) for single-record primary key lookup.
139
- def find(filter = {}, limit: 100, offset: 0, order_by: nil, include: nil, **extra_filter) # -> list[Self]
140
- # Integer or string-digit argument → primary key lookup (returns single record or nil)
141
- return find_by_id(filter) if filter.is_a?(Integer)
142
-
143
- # Merge keyword-style filters: find(name: "Alice") and find({name: "Alice"}) both work
144
- filter = filter.merge(extra_filter) unless extra_filter.empty?
145
- conditions = []
146
- params = []
147
-
148
- filter.each do |key, value|
149
- col = field_mapping[key.to_s] || key
150
- conditions << "#{col} = ?"
151
- params << value
130
+ def query
131
+ QueryBuilder.from(table_name, db: db)
132
+ end
133
+
134
+ def find(id_or_filter = nil, filter = nil, **kwargs)
135
+ include_list = kwargs.delete(:include)
136
+
137
+ # find(id) find by primary key
138
+ # find(filter_hash) find by criteria
139
+ # find(name: "Alice") keyword args as filter hash
140
+ result = if id_or_filter.is_a?(Hash)
141
+ find_by_filter(id_or_filter)
142
+ elsif filter.is_a?(Hash)
143
+ find_by_filter(filter)
144
+ elsif !kwargs.empty?
145
+ find_by_filter(kwargs)
146
+ else
147
+ find_by_id(id_or_filter)
152
148
  end
153
149
 
154
- if soft_delete
155
- conditions << "(#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
150
+ if include_list && result
151
+ instances = result.is_a?(Array) ? result : [result]
152
+ eager_load(instances, include_list)
156
153
  end
157
-
158
- sql = "SELECT * FROM #{table_name}"
159
- sql += " WHERE #{conditions.join(' AND ')}" unless conditions.empty?
160
- sql += " ORDER BY #{order_by}" if order_by
161
-
162
- results = db.fetch(sql, params, limit: limit, offset: offset)
163
- instances = results.map { |row| from_hash(row) }
164
- eager_load(instances, include) if include
165
- instances
154
+ result
166
155
  end
167
156
 
168
157
  # Eager load relationships for a collection of instances (prevents N+1).
@@ -243,20 +232,20 @@ module Tina4
243
232
  end
244
233
  end
245
234
 
246
- def where(conditions, params = [], limit: 20, offset: 0, include: nil) # -> list[Self]
235
+ def where(conditions, params = [], include: nil)
247
236
  sql = "SELECT * FROM #{table_name}"
248
237
  if soft_delete
249
238
  sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})"
250
239
  else
251
240
  sql += " WHERE #{conditions}"
252
241
  end
253
- results = db.fetch(sql, params, limit: limit, offset: offset)
242
+ results = db.fetch(sql, params)
254
243
  instances = results.map { |row| from_hash(row) }
255
244
  eager_load(instances, include) if include
256
245
  instances
257
246
  end
258
247
 
259
- def all(limit: nil, offset: nil, order_by: nil, include: nil) # -> list[Self]
248
+ def all(limit: nil, offset: nil, order_by: nil, include: nil)
260
249
  sql = "SELECT * FROM #{table_name}"
261
250
  if soft_delete
262
251
  sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0"
@@ -268,19 +257,19 @@ module Tina4
268
257
  instances
269
258
  end
270
259
 
271
- def select(sql, params = [], limit: nil, offset: nil, include: nil) # -> list[Self]
260
+ def select(sql, params = [], limit: nil, offset: nil, include: nil)
272
261
  results = db.fetch(sql, params, limit: limit, offset: offset)
273
262
  instances = results.map { |row| from_hash(row) }
274
263
  eager_load(instances, include) if include
275
264
  instances
276
265
  end
277
266
 
278
- def select_one(sql, params = [], include: nil) # -> Self | nil
267
+ def select_one(sql, params = [], include: nil)
279
268
  results = select(sql, params, limit: 1, include: include)
280
269
  results.first
281
270
  end
282
271
 
283
- def count(conditions = nil, params = []) # -> int
272
+ def count(conditions = nil, params = [])
284
273
  sql = "SELECT COUNT(*) as cnt FROM #{table_name}"
285
274
  where_parts = []
286
275
  if soft_delete
@@ -292,48 +281,25 @@ module Tina4
292
281
  result[:cnt].to_i
293
282
  end
294
283
 
295
- def create(attributes = {}) # -> Self
284
+ def create(attributes = {})
296
285
  instance = new(attributes)
297
286
  instance.save
298
287
  instance
299
288
  end
300
289
 
301
- def find_or_fail(id) # -> Self
290
+ def find_or_fail(id)
302
291
  result = find(id)
303
292
  raise "#{name} with #{primary_key_field || :id}=#{id} not found" if result.nil?
304
293
  result
305
294
  end
306
295
 
307
- # Return true if a record with the given primary key exists.
308
- def exists(pk_value) # -> bool
309
- find(pk_value) != nil
310
- end
311
-
312
- # SQL query with in-memory result caching.
313
- # Results are cached by (class, sql, params, limit, offset) for +ttl+ seconds.
314
- def cached(sql, params = [], ttl: 60, limit: 20, offset: 0, include: nil) # -> list[Self]
315
- @_query_cache ||= Tina4::QueryCache.new(default_ttl: ttl, max_size: 500)
316
- cache_key = Tina4::QueryCache.query_key("#{name}:#{sql}", params + [limit, offset])
317
- hit = @_query_cache.get(cache_key)
318
- return hit unless hit.nil?
319
-
320
- results = select(sql, params, limit: limit, offset: offset, include: include)
321
- @_query_cache.set(cache_key, results, ttl: ttl, tags: [name])
322
- results
323
- end
324
-
325
- # Clear all cached query results for this model.
326
- def clear_cache # -> nil
327
- @_query_cache&.clear_tag(name)
328
- end
329
-
330
- def with_trashed(conditions = "1=1", params = [], limit: 20, offset: 0) # -> list[Self]
296
+ def with_trashed(conditions = "1=1", params = [], limit: 20, offset: 0)
331
297
  sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
332
298
  results = db.fetch(sql, params, limit: limit, offset: offset)
333
299
  results.map { |row| from_hash(row) }
334
300
  end
335
301
 
336
- def create_table # -> bool
302
+ def create_table
337
303
  return true if db.table_exists?(table_name)
338
304
 
339
305
  type_map = {
@@ -374,7 +340,7 @@ module Tina4
374
340
  true
375
341
  end
376
342
 
377
- def scope(name, filter_sql, params = []) # -> nil
343
+ def scope(name, filter_sql, params = [])
378
344
  define_singleton_method(name) do |limit: 20, offset: 0|
379
345
  where(filter_sql, params)
380
346
  end
@@ -393,42 +359,17 @@ module Tina4
393
359
  instance
394
360
  end
395
361
 
396
- # Find a single record by primary key. Returns instance or nil.
397
- def find_by_id(id, include: nil) # -> Self | nil
362
+ # find_by_id is PUBLIC cross-framework parity with Python's
363
+ # MyModel.find_by_id(pk_value) and PHP's User::find($id). Spec at
364
+ # spec/orm_spec.rb:78 verifies public access. find_by_filter stays
365
+ # public for the same reason; both are part of the documented API.
366
+ def find_by_id(id)
398
367
  pk = primary_key_field || :id
399
368
  sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
400
369
  if soft_delete
401
370
  sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
402
371
  end
403
- select_one(sql, [id], include: include)
404
- end
405
-
406
- # Clear the relationship cache on all loaded instances (class-level helper).
407
- # Useful after bulk operations when you want to force relationship re-loads.
408
- def clear_rel_cache # -> nil
409
- @_rel_cache = {}
410
- nil
411
- end
412
-
413
- # Return the database connection used by this model.
414
- def get_db # -> Database
415
- db
416
- end
417
-
418
- # Map a Ruby property name to its database column name using field_mapping.
419
- # Returns the column name as a symbol.
420
- def get_db_column(property) # -> Symbol
421
- col = field_mapping[property.to_s] || property
422
- col.to_sym
423
- end
424
-
425
- private
426
-
427
- def auto_discover_db
428
- url = ENV["DATABASE_URL"]
429
- return nil unless url
430
- Tina4.database = Tina4::Database.new(url, username: ENV.fetch("DATABASE_USERNAME", ""), password: ENV.fetch("DATABASE_PASSWORD", ""))
431
- Tina4.database
372
+ select_one(sql, [id])
432
373
  end
433
374
 
434
375
  def find_by_filter(filter)
@@ -458,7 +399,7 @@ module Tina4
458
399
  end
459
400
  end
460
401
 
461
- def save # -> Self | bool
402
+ def save
462
403
  @errors = []
463
404
  @relationship_cache = {} # Clear relationship cache on save
464
405
  validate_fields
@@ -490,7 +431,7 @@ module Tina4
490
431
  false
491
432
  end
492
433
 
493
- def delete # -> bool
434
+ def delete
494
435
  pk = self.class.primary_key_field || :id
495
436
  pk_value = __send__(pk)
496
437
  return false unless pk_value
@@ -510,7 +451,7 @@ module Tina4
510
451
  true
511
452
  end
512
453
 
513
- def force_delete # -> bool
454
+ def force_delete
514
455
  pk = self.class.primary_key_field || :id
515
456
  pk_value = __send__(pk)
516
457
  raise "Cannot delete: no primary key value" unless pk_value
@@ -522,7 +463,7 @@ module Tina4
522
463
  true
523
464
  end
524
465
 
525
- def restore # -> bool
466
+ def restore
526
467
  raise "Model does not support soft delete" unless self.class.soft_delete
527
468
 
528
469
  pk = self.class.primary_key_field || :id
@@ -540,7 +481,7 @@ module Tina4
540
481
  true
541
482
  end
542
483
 
543
- def validate # -> list[str]
484
+ def validate
544
485
  errors = []
545
486
  self.class.field_definitions.each do |name, opts|
546
487
  value = __send__(name)
@@ -551,36 +492,34 @@ module Tina4
551
492
  errors
552
493
  end
553
494
 
554
- # Load a record into this instance via select_one.
555
- # Returns true if found and loaded, false otherwise.
556
- # Load a record into this instance.
495
+ # load populate this instance from the database.
557
496
  #
558
- # Usage:
559
- # orm.id = 1; orm.load — uses PK already set
560
- # orm.load("id = ?", [1]) — filter with params
561
- # orm.load("id = 1") filter string
497
+ # Three forms (parity with Python's model.load(sql, params, include)):
498
+ # user.load # reload by primary key from instance
499
+ # user.load(123) # load by primary key value
500
+ # user.load("email = ?", ["a@b.c"]) # load by filter SQL + params (selectOne)
562
501
  #
563
- # Returns true if a record was found, false otherwise.
564
- def load(filter = nil, params = [], include: nil) # -> bool
565
- @relationship_cache = {}
566
- table = self.class.table_name
567
-
568
- if filter.nil?
569
- pk = self.class.primary_key
570
- pk_col = self.class.field_mapping[pk.to_s] || pk
571
- pk_value = __send__(pk)
572
- return false if pk_value.nil?
573
- sql = "SELECT * FROM #{table} WHERE #{pk_col} = ?"
574
- params = [pk_value]
502
+ # Returns true on hit, false on miss. Always clears the relationship cache.
503
+ def load(arg = nil, params = nil)
504
+ @relationship_cache = {} # Clear relationship cache on reload
505
+ pk = self.class.primary_key_field || :id
506
+
507
+ if arg.is_a?(String)
508
+ # Filter-SQL form: user.load("email = ?", ["a@b.c"])
509
+ sql = "SELECT * FROM #{self.class.table_name} WHERE #{arg} LIMIT 1"
510
+ result = self.class.db.fetch_one(sql, params || [])
575
511
  else
576
- sql = "SELECT * FROM #{table} WHERE #{filter}"
512
+ # Primary-key form: user.load OR user.load(123)
513
+ id = arg || __send__(pk)
514
+ return false unless id
515
+ result = self.class.db.fetch_one(
516
+ "SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
517
+ )
577
518
  end
578
-
579
- result = self.class.select_one(sql, params, include: include)
580
519
  return false unless result
581
520
 
582
521
  mapping_reverse = self.class.field_mapping.invert
583
- result.to_h.each do |key, value|
522
+ result.each do |key, value|
584
523
  attr_name = mapping_reverse[key.to_s] || key
585
524
  setter = "#{attr_name}="
586
525
  __send__(setter, value) if respond_to?(setter)
@@ -599,8 +538,10 @@ module Tina4
599
538
 
600
539
  # Convert to hash using Ruby attribute names.
601
540
  # Optionally include relationships via the include keyword.
602
- # case: 'snake' (default) returns snake_case keys, 'camel' returns camelCase keys.
603
- def to_h(include: nil, case: 'snake') # -> dict
541
+ # case: "camel" converts snake_case keys to camelCase (parity with
542
+ # Python's to_dict(case='camel')). Default keeps native snake_case.
543
+ def to_h(include: nil, case: nil)
544
+ key_case = binding.local_variable_get(:case) # :case is a reserved word
604
545
  hash = {}
605
546
  self.class.field_definitions.each_key do |name|
606
547
  hash[name] = __send__(name)
@@ -622,49 +563,36 @@ module Tina4
622
563
  if related.nil?
623
564
  hash[rel_name] = nil
624
565
  elsif related.is_a?(Array)
625
- hash[rel_name] = related.map { |r| r.to_h(include: nested.empty? ? nil : nested, case: binding.local_variable_get(:case)) }
566
+ hash[rel_name] = related.map { |r| r.to_h(include: nested.empty? ? nil : nested) }
626
567
  else
627
- hash[rel_name] = related.to_h(include: nested.empty? ? nil : nested, case: binding.local_variable_get(:case))
568
+ hash[rel_name] = related.to_h(include: nested.empty? ? nil : nested)
628
569
  end
629
570
  end
630
571
  end
631
572
 
632
- case_mode = binding.local_variable_get(:case)
633
- if case_mode == 'camel'
634
- camel_hash = {}
635
- hash.each do |key, value|
636
- camel_key = Tina4.snake_to_camel(key.to_s).to_sym
637
- camel_hash[camel_key] = value
573
+ if key_case == "camel" || key_case == :camel
574
+ # snake_case camelCase: split on _, capitalize all but the first
575
+ hash = hash.each_with_object({}) do |(k, v), out|
576
+ parts = k.to_s.split("_")
577
+ camel = parts[0] + parts[1..].map(&:capitalize).join
578
+ out[camel.to_sym] = v
638
579
  end
639
- return camel_hash
640
580
  end
641
581
 
642
582
  hash
643
583
  end
644
584
 
645
- def to_hash(include: nil, case: 'snake')
646
- to_h(include: include, case: binding.local_variable_get(:case))
647
- end
648
-
649
- def to_dict(include: nil, case: 'snake')
650
- to_h(include: include, case: binding.local_variable_get(:case))
651
- end
585
+ alias to_hash to_h
586
+ alias to_dict to_h
587
+ alias to_object to_h
652
588
 
653
- def to_assoc(include: nil, case: 'snake')
654
- to_h(include: include, case: binding.local_variable_get(:case))
655
- end
656
-
657
- def to_object(include: nil, case: 'snake')
658
- to_h(include: include, case: binding.local_variable_get(:case))
659
- end
660
-
661
- def to_array # -> list
589
+ def to_array
662
590
  to_h.values
663
591
  end
664
592
 
665
593
  alias to_list to_array
666
594
 
667
- def to_json(include: nil, **_args) # -> str
595
+ def to_json(include: nil, **_args)
668
596
  JSON.generate(to_h(include: include))
669
597
  end
670
598
 
@@ -747,44 +675,7 @@ module Tina4
747
675
  fk_value = __send__(fk.to_sym) if respond_to?(fk.to_sym)
748
676
  return nil unless fk_value
749
677
 
750
- @relationship_cache[name] = klass.find_by_id(fk_value)
751
- end
752
-
753
- public
754
-
755
- # ── Imperative relationship methods (ad-hoc, like Python/PHP/Node) ──
756
-
757
- def has_one(related_class, foreign_key: nil)
758
- pk = self.class.primary_key_field || :id
759
- pk_value = __send__(pk)
760
- return nil unless pk_value
761
-
762
- fk = foreign_key || "#{self.class.name.split('::').last.downcase}_id"
763
- result = related_class.db.fetch_one(
764
- "SELECT * FROM #{related_class.table_name} WHERE #{fk} = ?", [pk_value]
765
- )
766
- result ? related_class.from_hash(result) : nil
767
- end
768
-
769
- def has_many(related_class, foreign_key: nil, limit: 100, offset: 0)
770
- pk = self.class.primary_key_field || :id
771
- pk_value = __send__(pk)
772
- return [] unless pk_value
773
-
774
- fk = foreign_key || "#{self.class.name.split('::').last.downcase}_id"
775
- results = related_class.db.fetch(
776
- "SELECT * FROM #{related_class.table_name} WHERE #{fk} = ?",
777
- [pk_value], limit: limit, offset: offset
778
- )
779
- results.map { |row| related_class.from_hash(row) }
780
- end
781
-
782
- def belongs_to(related_class, foreign_key: nil)
783
- fk = foreign_key || "#{related_class.name.split('::').last.downcase}_id"
784
- fk_value = respond_to?(fk.to_sym) ? __send__(fk.to_sym) : nil
785
- return nil unless fk_value
786
-
787
- related_class.find_by_id(fk_value)
678
+ @relationship_cache[name] = klass.find(fk_value)
788
679
  end
789
680
  end
790
681
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.11.32"
4
+ VERSION = "3.11.36"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.11.32
4
+ version: 3.11.36
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-29 00:00:00.000000000 Z
11
+ date: 2026-05-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack