tina4ruby 3.11.19 → 3.11.35

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.
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
@@ -54,32 +54,20 @@ module Tina4
54
54
 
55
55
  # Auto-map flag (no-op in Ruby since snake_case is native)
56
56
  def auto_map
57
- @auto_map.nil? ? true : @auto_map
57
+ @auto_map || false
58
58
  end
59
59
 
60
60
  def auto_map=(val)
61
61
  @auto_map = val
62
62
  end
63
63
 
64
- # Auto-CRUD flag: when set to true, registers this model for CRUD route generation
65
- def auto_crud
66
- @auto_crud || false
67
- end
68
-
69
- def auto_crud=(val)
70
- @auto_crud = val
71
- if val
72
- Tina4::AutoCrud.register(self) if defined?(Tina4::AutoCrud)
73
- end
74
- end
75
-
76
64
  # Relationship definitions
77
65
  def relationship_definitions
78
66
  @relationship_definitions ||= {}
79
67
  end
80
68
 
81
69
  # has_one :profile, class_name: "Profile", foreign_key: "user_id"
82
- def has_one(name, class_name: nil, foreign_key: nil) # -> nil
70
+ def has_one(name, class_name: nil, foreign_key: nil)
83
71
  relationship_definitions[name] = {
84
72
  type: :has_one,
85
73
  class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
@@ -92,7 +80,7 @@ module Tina4
92
80
  end
93
81
 
94
82
  # has_many :posts, class_name: "Post", foreign_key: "user_id"
95
- def has_many(name, class_name: nil, foreign_key: nil) # -> nil
83
+ def has_many(name, class_name: nil, foreign_key: nil)
96
84
  relationship_definitions[name] = {
97
85
  type: :has_many,
98
86
  class_name: class_name || name.to_s.sub(/s$/, "").split("_").map(&:capitalize).join,
@@ -105,7 +93,7 @@ module Tina4
105
93
  end
106
94
 
107
95
  # belongs_to :user, class_name: "User", foreign_key: "user_id"
108
- def belongs_to(name, class_name: nil, foreign_key: nil) # -> nil
96
+ def belongs_to(name, class_name: nil, foreign_key: nil)
109
97
  relationship_definitions[name] = {
110
98
  type: :belongs_to,
111
99
  class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
@@ -123,46 +111,31 @@ module Tina4
123
111
  # results = User.query.where("active = ?", [1]).order_by("name").get
124
112
  #
125
113
  # @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
114
+ def query
115
+ QueryBuilder.from(table_name, db: db)
116
+ end
117
+
118
+ def find(id_or_filter = nil, filter = nil, **kwargs)
119
+ include_list = kwargs.delete(:include)
120
+
121
+ # find(id) find by primary key
122
+ # find(filter_hash) find by criteria
123
+ # find(name: "Alice") keyword args as filter hash
124
+ result = if id_or_filter.is_a?(Hash)
125
+ find_by_filter(id_or_filter)
126
+ elsif filter.is_a?(Hash)
127
+ find_by_filter(filter)
128
+ elsif !kwargs.empty?
129
+ find_by_filter(kwargs)
130
+ else
131
+ find_by_id(id_or_filter)
152
132
  end
153
133
 
154
- if soft_delete
155
- conditions << "(#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
134
+ if include_list && result
135
+ instances = result.is_a?(Array) ? result : [result]
136
+ eager_load(instances, include_list)
156
137
  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
138
+ result
166
139
  end
167
140
 
168
141
  # Eager load relationships for a collection of instances (prevents N+1).
@@ -243,20 +216,20 @@ module Tina4
243
216
  end
244
217
  end
245
218
 
246
- def where(conditions, params = [], limit: 20, offset: 0, include: nil) # -> list[Self]
219
+ def where(conditions, params = [], include: nil)
247
220
  sql = "SELECT * FROM #{table_name}"
248
221
  if soft_delete
249
222
  sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})"
250
223
  else
251
224
  sql += " WHERE #{conditions}"
252
225
  end
253
- results = db.fetch(sql, params, limit: limit, offset: offset)
226
+ results = db.fetch(sql, params)
254
227
  instances = results.map { |row| from_hash(row) }
255
228
  eager_load(instances, include) if include
256
229
  instances
257
230
  end
258
231
 
259
- def all(limit: nil, offset: nil, order_by: nil, include: nil) # -> list[Self]
232
+ def all(limit: nil, offset: nil, order_by: nil, include: nil)
260
233
  sql = "SELECT * FROM #{table_name}"
261
234
  if soft_delete
262
235
  sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0"
@@ -268,19 +241,19 @@ module Tina4
268
241
  instances
269
242
  end
270
243
 
271
- def select(sql, params = [], limit: nil, offset: nil, include: nil) # -> list[Self]
244
+ def select(sql, params = [], limit: nil, offset: nil, include: nil)
272
245
  results = db.fetch(sql, params, limit: limit, offset: offset)
273
246
  instances = results.map { |row| from_hash(row) }
274
247
  eager_load(instances, include) if include
275
248
  instances
276
249
  end
277
250
 
278
- def select_one(sql, params = [], include: nil) # -> Self | nil
251
+ def select_one(sql, params = [], include: nil)
279
252
  results = select(sql, params, limit: 1, include: include)
280
253
  results.first
281
254
  end
282
255
 
283
- def count(conditions = nil, params = []) # -> int
256
+ def count(conditions = nil, params = [])
284
257
  sql = "SELECT COUNT(*) as cnt FROM #{table_name}"
285
258
  where_parts = []
286
259
  if soft_delete
@@ -292,48 +265,25 @@ module Tina4
292
265
  result[:cnt].to_i
293
266
  end
294
267
 
295
- def create(attributes = {}) # -> Self
268
+ def create(attributes = {})
296
269
  instance = new(attributes)
297
270
  instance.save
298
271
  instance
299
272
  end
300
273
 
301
- def find_or_fail(id) # -> Self
274
+ def find_or_fail(id)
302
275
  result = find(id)
303
276
  raise "#{name} with #{primary_key_field || :id}=#{id} not found" if result.nil?
304
277
  result
305
278
  end
306
279
 
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]
280
+ def with_trashed(conditions = "1=1", params = [], limit: 20, offset: 0)
331
281
  sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
332
282
  results = db.fetch(sql, params, limit: limit, offset: offset)
333
283
  results.map { |row| from_hash(row) }
334
284
  end
335
285
 
336
- def create_table # -> bool
286
+ def create_table
337
287
  return true if db.table_exists?(table_name)
338
288
 
339
289
  type_map = {
@@ -374,7 +324,7 @@ module Tina4
374
324
  true
375
325
  end
376
326
 
377
- def scope(name, filter_sql, params = []) # -> nil
327
+ def scope(name, filter_sql, params = [])
378
328
  define_singleton_method(name) do |limit: 20, offset: 0|
379
329
  where(filter_sql, params)
380
330
  end
@@ -393,42 +343,15 @@ module Tina4
393
343
  instance
394
344
  end
395
345
 
396
- # Find a single record by primary key. Returns instance or nil.
397
- def find_by_id(id, include: nil) # -> Self | nil
346
+ private
347
+
348
+ def find_by_id(id)
398
349
  pk = primary_key_field || :id
399
350
  sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
400
351
  if soft_delete
401
352
  sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
402
353
  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
354
+ select_one(sql, [id])
432
355
  end
433
356
 
434
357
  def find_by_filter(filter)
@@ -458,7 +381,7 @@ module Tina4
458
381
  end
459
382
  end
460
383
 
461
- def save # -> Self | bool
384
+ def save
462
385
  @errors = []
463
386
  @relationship_cache = {} # Clear relationship cache on save
464
387
  validate_fields
@@ -490,7 +413,7 @@ module Tina4
490
413
  false
491
414
  end
492
415
 
493
- def delete # -> bool
416
+ def delete
494
417
  pk = self.class.primary_key_field || :id
495
418
  pk_value = __send__(pk)
496
419
  return false unless pk_value
@@ -510,7 +433,7 @@ module Tina4
510
433
  true
511
434
  end
512
435
 
513
- def force_delete # -> bool
436
+ def force_delete
514
437
  pk = self.class.primary_key_field || :id
515
438
  pk_value = __send__(pk)
516
439
  raise "Cannot delete: no primary key value" unless pk_value
@@ -522,7 +445,7 @@ module Tina4
522
445
  true
523
446
  end
524
447
 
525
- def restore # -> bool
448
+ def restore
526
449
  raise "Model does not support soft delete" unless self.class.soft_delete
527
450
 
528
451
  pk = self.class.primary_key_field || :id
@@ -540,7 +463,7 @@ module Tina4
540
463
  true
541
464
  end
542
465
 
543
- def validate # -> list[str]
466
+ def validate
544
467
  errors = []
545
468
  self.class.field_definitions.each do |name, opts|
546
469
  value = __send__(name)
@@ -551,36 +474,19 @@ module Tina4
551
474
  errors
552
475
  end
553
476
 
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.
557
- #
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
562
- #
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]
575
- else
576
- sql = "SELECT * FROM #{table} WHERE #{filter}"
577
- end
477
+ def load(id = nil)
478
+ pk = self.class.primary_key_field || :id
479
+ id ||= __send__(pk)
480
+ return false unless id
481
+ @relationship_cache = {} # Clear relationship cache on reload
578
482
 
579
- result = self.class.select_one(sql, params, include: include)
483
+ result = self.class.db.fetch_one(
484
+ "SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
485
+ )
580
486
  return false unless result
581
487
 
582
488
  mapping_reverse = self.class.field_mapping.invert
583
- result.to_h.each do |key, value|
489
+ result.each do |key, value|
584
490
  attr_name = mapping_reverse[key.to_s] || key
585
491
  setter = "#{attr_name}="
586
492
  __send__(setter, value) if respond_to?(setter)
@@ -599,8 +505,7 @@ module Tina4
599
505
 
600
506
  # Convert to hash using Ruby attribute names.
601
507
  # 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
508
+ def to_h(include: nil)
604
509
  hash = {}
605
510
  self.class.field_definitions.each_key do |name|
606
511
  hash[name] = __send__(name)
@@ -622,49 +527,27 @@ module Tina4
622
527
  if related.nil?
623
528
  hash[rel_name] = nil
624
529
  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)) }
530
+ hash[rel_name] = related.map { |r| r.to_h(include: nested.empty? ? nil : nested) }
626
531
  else
627
- hash[rel_name] = related.to_h(include: nested.empty? ? nil : nested, case: binding.local_variable_get(:case))
532
+ hash[rel_name] = related.to_h(include: nested.empty? ? nil : nested)
628
533
  end
629
534
  end
630
535
  end
631
536
 
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
638
- end
639
- return camel_hash
640
- end
641
-
642
537
  hash
643
538
  end
644
539
 
645
- def to_hash(include: nil, case: 'snake')
646
- to_h(include: include, case: binding.local_variable_get(:case))
647
- end
540
+ alias to_hash to_h
541
+ alias to_dict to_h
542
+ alias to_object to_h
648
543
 
649
- def to_dict(include: nil, case: 'snake')
650
- to_h(include: include, case: binding.local_variable_get(:case))
651
- end
652
-
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
544
+ def to_array
662
545
  to_h.values
663
546
  end
664
547
 
665
548
  alias to_list to_array
666
549
 
667
- def to_json(include: nil, **_args) # -> str
550
+ def to_json(include: nil, **_args)
668
551
  JSON.generate(to_h(include: include))
669
552
  end
670
553
 
@@ -747,44 +630,7 @@ module Tina4
747
630
  fk_value = __send__(fk.to_sym) if respond_to?(fk.to_sym)
748
631
  return nil unless fk_value
749
632
 
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)
633
+ @relationship_cache[name] = klass.find(fk_value)
788
634
  end
789
635
  end
790
636
  end