tina4ruby 3.11.13 → 3.11.15

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
data/lib/tina4/orm.rb CHANGED
@@ -1,790 +1,790 @@
1
- # frozen_string_literal: true
2
- require "json"
3
-
4
- module Tina4
5
- # Convert a snake_case name to camelCase.
6
- def self.snake_to_camel(name)
7
- parts = name.to_s.downcase.split("_")
8
- parts[0] + parts[1..].map(&:capitalize).join
9
- end
10
-
11
- # Convert a camelCase name to snake_case.
12
- def self.camel_to_snake(name)
13
- name.to_s.gsub(/([A-Z])/) { "_#{$1.downcase}" }.sub(/^_/, "")
14
- end
15
-
16
- class ORM
17
- include Tina4::FieldTypes
18
-
19
- class << self
20
- def db
21
- @db || Tina4.database || auto_discover_db
22
- end
23
-
24
- # Per-model database binding
25
- def db=(database)
26
- @db = database
27
- end
28
-
29
- # Soft delete configuration
30
- def soft_delete
31
- @soft_delete || false
32
- end
33
-
34
- def soft_delete=(val)
35
- @soft_delete = val
36
- end
37
-
38
- def soft_delete_field
39
- @soft_delete_field || :is_deleted
40
- end
41
-
42
- def soft_delete_field=(val)
43
- @soft_delete_field = val
44
- end
45
-
46
- # Field mapping: { 'db_column' => 'ruby_attribute' }
47
- def field_mapping
48
- @field_mapping || {}
49
- end
50
-
51
- def field_mapping=(map)
52
- @field_mapping = map
53
- end
54
-
55
- # Auto-map flag (no-op in Ruby since snake_case is native)
56
- def auto_map
57
- @auto_map.nil? ? true : @auto_map
58
- end
59
-
60
- def auto_map=(val)
61
- @auto_map = val
62
- end
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
- # Relationship definitions
77
- def relationship_definitions
78
- @relationship_definitions ||= {}
79
- end
80
-
81
- # has_one :profile, class_name: "Profile", foreign_key: "user_id"
82
- def has_one(name, class_name: nil, foreign_key: nil) # -> nil
83
- relationship_definitions[name] = {
84
- type: :has_one,
85
- class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
86
- foreign_key: foreign_key
87
- }
88
-
89
- define_method(name) do
90
- load_has_one(name)
91
- end
92
- end
93
-
94
- # has_many :posts, class_name: "Post", foreign_key: "user_id"
95
- def has_many(name, class_name: nil, foreign_key: nil) # -> nil
96
- relationship_definitions[name] = {
97
- type: :has_many,
98
- class_name: class_name || name.to_s.sub(/s$/, "").split("_").map(&:capitalize).join,
99
- foreign_key: foreign_key
100
- }
101
-
102
- define_method(name) do
103
- load_has_many(name)
104
- end
105
- end
106
-
107
- # belongs_to :user, class_name: "User", foreign_key: "user_id"
108
- def belongs_to(name, class_name: nil, foreign_key: nil) # -> nil
109
- relationship_definitions[name] = {
110
- type: :belongs_to,
111
- class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
112
- foreign_key: foreign_key || "#{name}_id"
113
- }
114
-
115
- define_method(name) do
116
- load_belongs_to(name)
117
- end
118
- end
119
-
120
- # Create a fluent QueryBuilder pre-configured for this model's table and database.
121
- #
122
- # Usage:
123
- # results = User.query.where("active = ?", [1]).order_by("name").get
124
- #
125
- # @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
152
- end
153
-
154
- if soft_delete
155
- conditions << "(#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
156
- 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
166
- end
167
-
168
- # Eager load relationships for a collection of instances (prevents N+1).
169
- # include is an array of relationship names, supporting dot notation for nesting.
170
- def eager_load(instances, include_list)
171
- return if instances.nil? || instances.empty?
172
-
173
- # Group includes: top-level and nested
174
- top_level = {}
175
- include_list.each do |inc|
176
- parts = inc.to_s.split(".", 2)
177
- rel_name = parts[0].to_sym
178
- top_level[rel_name] ||= []
179
- top_level[rel_name] << parts[1] if parts.length > 1
180
- end
181
-
182
- top_level.each do |rel_name, nested|
183
- rel = relationship_definitions[rel_name]
184
- next unless rel
185
-
186
- klass = Object.const_get(rel[:class_name])
187
- pk = primary_key_field || :id
188
-
189
- case rel[:type]
190
- when :has_one, :has_many
191
- fk = rel[:foreign_key] || "#{name.split('::').last.downcase}_id"
192
- pk_values = instances.map { |inst| inst.__send__(pk) }.compact.uniq
193
- next if pk_values.empty?
194
-
195
- placeholders = pk_values.map { "?" }.join(",")
196
- sql = "SELECT * FROM #{klass.table_name} WHERE #{fk} IN (#{placeholders})"
197
- results = klass.db.fetch(sql, pk_values)
198
- related_records = results.map { |row| klass.from_hash(row) }
199
-
200
- # Eager load nested
201
- klass.eager_load(related_records, nested) unless nested.empty?
202
-
203
- # Group by FK
204
- grouped = {}
205
- related_records.each do |record|
206
- fk_val = record.__send__(fk.to_sym) if record.respond_to?(fk.to_sym)
207
- (grouped[fk_val] ||= []) << record
208
- end
209
-
210
- instances.each do |inst|
211
- pk_val = inst.__send__(pk)
212
- records = grouped[pk_val] || []
213
- if rel[:type] == :has_one
214
- inst.instance_variable_get(:@relationship_cache)[rel_name] = records.first
215
- else
216
- inst.instance_variable_get(:@relationship_cache)[rel_name] = records
217
- end
218
- end
219
-
220
- when :belongs_to
221
- fk = rel[:foreign_key] || "#{rel_name}_id"
222
- fk_values = instances.map { |inst|
223
- inst.respond_to?(fk.to_sym) ? inst.__send__(fk.to_sym) : nil
224
- }.compact.uniq
225
- next if fk_values.empty?
226
-
227
- related_pk = klass.primary_key_field || :id
228
- placeholders = fk_values.map { "?" }.join(",")
229
- sql = "SELECT * FROM #{klass.table_name} WHERE #{related_pk} IN (#{placeholders})"
230
- results = klass.db.fetch(sql, fk_values)
231
- related_records = results.map { |row| klass.from_hash(row) }
232
-
233
- klass.eager_load(related_records, nested) unless nested.empty?
234
-
235
- lookup = {}
236
- related_records.each { |r| lookup[r.__send__(related_pk)] = r }
237
-
238
- instances.each do |inst|
239
- fk_val = inst.respond_to?(fk.to_sym) ? inst.__send__(fk.to_sym) : nil
240
- inst.instance_variable_get(:@relationship_cache)[rel_name] = lookup[fk_val]
241
- end
242
- end
243
- end
244
- end
245
-
246
- def where(conditions, params = [], limit: 20, offset: 0, include: nil) # -> list[Self]
247
- sql = "SELECT * FROM #{table_name}"
248
- if soft_delete
249
- sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})"
250
- else
251
- sql += " WHERE #{conditions}"
252
- end
253
- results = db.fetch(sql, params, limit: limit, offset: offset)
254
- instances = results.map { |row| from_hash(row) }
255
- eager_load(instances, include) if include
256
- instances
257
- end
258
-
259
- def all(limit: nil, offset: nil, order_by: nil, include: nil) # -> list[Self]
260
- sql = "SELECT * FROM #{table_name}"
261
- if soft_delete
262
- sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0"
263
- end
264
- sql += " ORDER BY #{order_by}" if order_by
265
- results = db.fetch(sql, [], limit: limit, offset: offset)
266
- instances = results.map { |row| from_hash(row) }
267
- eager_load(instances, include) if include
268
- instances
269
- end
270
-
271
- def select(sql, params = [], limit: nil, offset: nil, include: nil) # -> list[Self]
272
- results = db.fetch(sql, params, limit: limit, offset: offset)
273
- instances = results.map { |row| from_hash(row) }
274
- eager_load(instances, include) if include
275
- instances
276
- end
277
-
278
- def select_one(sql, params = [], include: nil) # -> Self | nil
279
- results = select(sql, params, limit: 1, include: include)
280
- results.first
281
- end
282
-
283
- def count(conditions = nil, params = []) # -> int
284
- sql = "SELECT COUNT(*) as cnt FROM #{table_name}"
285
- where_parts = []
286
- if soft_delete
287
- where_parts << "(#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
288
- end
289
- where_parts << "(#{conditions})" if conditions
290
- sql += " WHERE #{where_parts.join(' AND ')}" unless where_parts.empty?
291
- result = db.fetch_one(sql, params)
292
- result[:cnt].to_i
293
- end
294
-
295
- def create(attributes = {}) # -> Self
296
- instance = new(attributes)
297
- instance.save
298
- instance
299
- end
300
-
301
- def find_or_fail(id) # -> Self
302
- result = find(id)
303
- raise "#{name} with #{primary_key_field || :id}=#{id} not found" if result.nil?
304
- result
305
- end
306
-
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]
331
- sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
332
- results = db.fetch(sql, params, limit: limit, offset: offset)
333
- results.map { |row| from_hash(row) }
334
- end
335
-
336
- def create_table # -> bool
337
- return true if db.table_exists?(table_name)
338
-
339
- type_map = {
340
- integer: "INTEGER",
341
- string: "VARCHAR(255)",
342
- text: "TEXT",
343
- float: "REAL",
344
- decimal: "REAL",
345
- boolean: "INTEGER",
346
- date: "DATE",
347
- datetime: "DATETIME",
348
- timestamp: "TIMESTAMP",
349
- blob: "BLOB",
350
- json: "TEXT"
351
- }
352
-
353
- col_defs = []
354
- field_definitions.each do |name, opts|
355
- sql_type = type_map[opts[:type]] || "TEXT"
356
- if opts[:type] == :string && opts[:length]
357
- sql_type = "VARCHAR(#{opts[:length]})"
358
- end
359
-
360
- parts = ["#{name} #{sql_type}"]
361
- parts << "PRIMARY KEY" if opts[:primary_key]
362
- parts << "AUTOINCREMENT" if opts[:auto_increment]
363
- parts << "NOT NULL" if !opts[:nullable] && !opts[:primary_key]
364
- if opts[:default] && !opts[:auto_increment]
365
- default_val = opts[:default].is_a?(String) ? "'#{opts[:default]}'" : opts[:default]
366
- parts << "DEFAULT #{default_val}"
367
- end
368
- col_defs << parts.join(" ")
369
- end
370
-
371
- sql = "CREATE TABLE IF NOT EXISTS #{table_name} (#{col_defs.join(', ')})"
372
- db.execute(sql)
373
- db.commit
374
- true
375
- end
376
-
377
- def scope(name, filter_sql, params = []) # -> nil
378
- define_singleton_method(name) do |limit: 20, offset: 0|
379
- where(filter_sql, params)
380
- end
381
- end
382
-
383
- def from_hash(hash)
384
- instance = new
385
- mapping_reverse = field_mapping.invert
386
- hash.each do |key, value|
387
- # Apply field mapping (db_col => ruby_attr)
388
- attr_name = mapping_reverse[key.to_s] || key
389
- setter = "#{attr_name}="
390
- instance.__send__(setter, value) if instance.respond_to?(setter)
391
- end
392
- instance.instance_variable_set(:@persisted, true)
393
- instance
394
- end
395
-
396
- # Find a single record by primary key. Returns instance or nil.
397
- def find_by_id(id, include: nil) # -> Self | nil
398
- pk = primary_key_field || :id
399
- sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
400
- if soft_delete
401
- sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
402
- 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
432
- end
433
-
434
- def find_by_filter(filter)
435
- where_parts = filter.keys.map { |k| "#{k} = ?" }
436
- sql = "SELECT * FROM #{table_name} WHERE #{where_parts.join(' AND ')}"
437
- if soft_delete
438
- sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
439
- end
440
- results = db.fetch(sql, filter.values)
441
- results.map { |row| from_hash(row) }
442
- end
443
- end
444
-
445
- def initialize(attributes = {})
446
- @persisted = false
447
- @errors = []
448
- @relationship_cache = {}
449
- attributes.each do |key, value|
450
- setter = "#{key}="
451
- __send__(setter, value) if respond_to?(setter)
452
- end
453
- # Set defaults
454
- self.class.field_definitions.each do |name, opts|
455
- if __send__(name).nil? && opts[:default]
456
- __send__("#{name}=", opts[:default])
457
- end
458
- end
459
- end
460
-
461
- def save # -> Self | bool
462
- @errors = []
463
- @relationship_cache = {} # Clear relationship cache on save
464
- validate_fields
465
- return false unless @errors.empty?
466
-
467
- data = to_db_hash(exclude_nil: true)
468
- pk = self.class.primary_key_field || :id
469
- pk_value = __send__(pk)
470
-
471
- self.class.db.transaction do |db|
472
- if @persisted && pk_value
473
- filter = { pk => pk_value }
474
- data.delete(pk)
475
- # Remove mapped primary key too
476
- mapped_pk = self.class.field_mapping[pk.to_s]
477
- data.delete(mapped_pk.to_sym) if mapped_pk
478
- db.update(self.class.table_name, data, filter)
479
- else
480
- result = db.insert(self.class.table_name, data)
481
- if result[:last_id] && respond_to?("#{pk}=")
482
- __send__("#{pk}=", result[:last_id])
483
- end
484
- @persisted = true
485
- end
486
- end
487
- true
488
- rescue => e
489
- @errors << e.message
490
- false
491
- end
492
-
493
- def delete # -> bool
494
- pk = self.class.primary_key_field || :id
495
- pk_value = __send__(pk)
496
- return false unless pk_value
497
-
498
- self.class.db.transaction do |db|
499
- if self.class.soft_delete
500
- db.update(
501
- self.class.table_name,
502
- { self.class.soft_delete_field => 1 },
503
- { pk => pk_value }
504
- )
505
- else
506
- db.delete(self.class.table_name, { pk => pk_value })
507
- end
508
- end
509
- @persisted = false
510
- true
511
- end
512
-
513
- def force_delete # -> bool
514
- pk = self.class.primary_key_field || :id
515
- pk_value = __send__(pk)
516
- raise "Cannot delete: no primary key value" unless pk_value
517
-
518
- self.class.db.transaction do |db|
519
- db.delete(self.class.table_name, { pk => pk_value })
520
- end
521
- @persisted = false
522
- true
523
- end
524
-
525
- def restore # -> bool
526
- raise "Model does not support soft delete" unless self.class.soft_delete
527
-
528
- pk = self.class.primary_key_field || :id
529
- pk_value = __send__(pk)
530
- raise "Cannot restore: no primary key value" unless pk_value
531
-
532
- self.class.db.transaction do |db|
533
- db.update(
534
- self.class.table_name,
535
- { self.class.soft_delete_field => 0 },
536
- { pk => pk_value }
537
- )
538
- end
539
- __send__("#{self.class.soft_delete_field}=", 0) if respond_to?("#{self.class.soft_delete_field}=")
540
- true
541
- end
542
-
543
- def validate # -> list[str]
544
- errors = []
545
- self.class.field_definitions.each do |name, opts|
546
- value = __send__(name)
547
- if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default]
548
- errors << "#{name} cannot be null"
549
- end
550
- end
551
- errors
552
- end
553
-
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
578
-
579
- result = self.class.select_one(sql, params, include: include)
580
- return false unless result
581
-
582
- mapping_reverse = self.class.field_mapping.invert
583
- result.to_h.each do |key, value|
584
- attr_name = mapping_reverse[key.to_s] || key
585
- setter = "#{attr_name}="
586
- __send__(setter, value) if respond_to?(setter)
587
- end
588
- @persisted = true
589
- true
590
- end
591
-
592
- def persisted?
593
- @persisted
594
- end
595
-
596
- def errors
597
- @errors
598
- end
599
-
600
- # Convert to hash using Ruby attribute names.
601
- # 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
604
- hash = {}
605
- self.class.field_definitions.each_key do |name|
606
- hash[name] = __send__(name)
607
- end
608
-
609
- if include
610
- # Group includes: top-level and nested
611
- top_level = {}
612
- include.each do |inc|
613
- parts = inc.to_s.split(".", 2)
614
- rel_name = parts[0].to_sym
615
- top_level[rel_name] ||= []
616
- top_level[rel_name] << parts[1] if parts.length > 1
617
- end
618
-
619
- top_level.each do |rel_name, nested|
620
- next unless self.class.relationship_definitions.key?(rel_name)
621
- related = __send__(rel_name)
622
- if related.nil?
623
- hash[rel_name] = nil
624
- 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)) }
626
- else
627
- hash[rel_name] = related.to_h(include: nested.empty? ? nil : nested, case: binding.local_variable_get(:case))
628
- end
629
- end
630
- end
631
-
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
- hash
643
- end
644
-
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
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
662
- to_h.values
663
- end
664
-
665
- alias to_list to_array
666
-
667
- def to_json(include: nil, **_args) # -> str
668
- JSON.generate(to_h(include: include))
669
- end
670
-
671
- def to_s
672
- "#<#{self.class.name} #{to_h}>"
673
- end
674
-
675
- def select(*fields)
676
- fields_str = fields.map(&:to_s).join(", ")
677
- pk = self.class.primary_key_field || :id
678
- pk_value = __send__(pk)
679
- self.class.db.fetch_one("SELECT #{fields_str} FROM #{self.class.table_name} WHERE #{pk} = ?", [pk_value])
680
- end
681
-
682
- private
683
-
684
- # Convert to hash using DB column names (with field_mapping applied)
685
- def to_db_hash(exclude_nil: false)
686
- hash = {}
687
- mapping = self.class.field_mapping
688
- self.class.field_definitions.each_key do |name|
689
- value = __send__(name)
690
- next if exclude_nil && value.nil?
691
- db_col = mapping[name.to_s] || name
692
- hash[db_col.to_sym] = value
693
- end
694
- hash
695
- end
696
-
697
- def validate_fields
698
- self.class.field_definitions.each do |name, opts|
699
- value = __send__(name)
700
- if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default]
701
- @errors << "#{name} cannot be null"
702
- end
703
- end
704
- end
705
-
706
- def load_has_one(name)
707
- return @relationship_cache[name] if @relationship_cache.key?(name)
708
- rel = self.class.relationship_definitions[name]
709
- return nil unless rel
710
-
711
- klass = Object.const_get(rel[:class_name])
712
- pk = self.class.primary_key_field || :id
713
- fk = rel[:foreign_key] || "#{self.class.name.split('::').last.downcase}_id"
714
- pk_value = __send__(pk)
715
- return nil unless pk_value
716
-
717
- result = klass.db.fetch_one(
718
- "SELECT * FROM #{klass.table_name} WHERE #{fk} = ?", [pk_value]
719
- )
720
- @relationship_cache[name] = result ? klass.from_hash(result) : nil
721
- end
722
-
723
- def load_has_many(name)
724
- return @relationship_cache[name] if @relationship_cache.key?(name)
725
- rel = self.class.relationship_definitions[name]
726
- return [] unless rel
727
-
728
- klass = Object.const_get(rel[:class_name])
729
- pk = self.class.primary_key_field || :id
730
- fk = rel[:foreign_key] || "#{self.class.name.split('::').last.downcase}_id"
731
- pk_value = __send__(pk)
732
- return [] unless pk_value
733
-
734
- results = klass.db.fetch(
735
- "SELECT * FROM #{klass.table_name} WHERE #{fk} = ?", [pk_value]
736
- )
737
- @relationship_cache[name] = results.map { |row| klass.from_hash(row) }
738
- end
739
-
740
- def load_belongs_to(name)
741
- return @relationship_cache[name] if @relationship_cache.key?(name)
742
- rel = self.class.relationship_definitions[name]
743
- return nil unless rel
744
-
745
- klass = Object.const_get(rel[:class_name])
746
- fk = rel[:foreign_key] || "#{name}_id"
747
- fk_value = __send__(fk.to_sym) if respond_to?(fk.to_sym)
748
- return nil unless fk_value
749
-
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)
788
- end
789
- end
790
- end
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Tina4
5
+ # Convert a snake_case name to camelCase.
6
+ def self.snake_to_camel(name)
7
+ parts = name.to_s.downcase.split("_")
8
+ parts[0] + parts[1..].map(&:capitalize).join
9
+ end
10
+
11
+ # Convert a camelCase name to snake_case.
12
+ def self.camel_to_snake(name)
13
+ name.to_s.gsub(/([A-Z])/) { "_#{$1.downcase}" }.sub(/^_/, "")
14
+ end
15
+
16
+ class ORM
17
+ include Tina4::FieldTypes
18
+
19
+ class << self
20
+ def db
21
+ @db || Tina4.database || auto_discover_db
22
+ end
23
+
24
+ # Per-model database binding
25
+ def db=(database)
26
+ @db = database
27
+ end
28
+
29
+ # Soft delete configuration
30
+ def soft_delete
31
+ @soft_delete || false
32
+ end
33
+
34
+ def soft_delete=(val)
35
+ @soft_delete = val
36
+ end
37
+
38
+ def soft_delete_field
39
+ @soft_delete_field || :is_deleted
40
+ end
41
+
42
+ def soft_delete_field=(val)
43
+ @soft_delete_field = val
44
+ end
45
+
46
+ # Field mapping: { 'db_column' => 'ruby_attribute' }
47
+ def field_mapping
48
+ @field_mapping || {}
49
+ end
50
+
51
+ def field_mapping=(map)
52
+ @field_mapping = map
53
+ end
54
+
55
+ # Auto-map flag (no-op in Ruby since snake_case is native)
56
+ def auto_map
57
+ @auto_map.nil? ? true : @auto_map
58
+ end
59
+
60
+ def auto_map=(val)
61
+ @auto_map = val
62
+ end
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
+ # Relationship definitions
77
+ def relationship_definitions
78
+ @relationship_definitions ||= {}
79
+ end
80
+
81
+ # has_one :profile, class_name: "Profile", foreign_key: "user_id"
82
+ def has_one(name, class_name: nil, foreign_key: nil) # -> nil
83
+ relationship_definitions[name] = {
84
+ type: :has_one,
85
+ class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
86
+ foreign_key: foreign_key
87
+ }
88
+
89
+ define_method(name) do
90
+ load_has_one(name)
91
+ end
92
+ end
93
+
94
+ # has_many :posts, class_name: "Post", foreign_key: "user_id"
95
+ def has_many(name, class_name: nil, foreign_key: nil) # -> nil
96
+ relationship_definitions[name] = {
97
+ type: :has_many,
98
+ class_name: class_name || name.to_s.sub(/s$/, "").split("_").map(&:capitalize).join,
99
+ foreign_key: foreign_key
100
+ }
101
+
102
+ define_method(name) do
103
+ load_has_many(name)
104
+ end
105
+ end
106
+
107
+ # belongs_to :user, class_name: "User", foreign_key: "user_id"
108
+ def belongs_to(name, class_name: nil, foreign_key: nil) # -> nil
109
+ relationship_definitions[name] = {
110
+ type: :belongs_to,
111
+ class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
112
+ foreign_key: foreign_key || "#{name}_id"
113
+ }
114
+
115
+ define_method(name) do
116
+ load_belongs_to(name)
117
+ end
118
+ end
119
+
120
+ # Create a fluent QueryBuilder pre-configured for this model's table and database.
121
+ #
122
+ # Usage:
123
+ # results = User.query.where("active = ?", [1]).order_by("name").get
124
+ #
125
+ # @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
152
+ end
153
+
154
+ if soft_delete
155
+ conditions << "(#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
156
+ 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
166
+ end
167
+
168
+ # Eager load relationships for a collection of instances (prevents N+1).
169
+ # include is an array of relationship names, supporting dot notation for nesting.
170
+ def eager_load(instances, include_list)
171
+ return if instances.nil? || instances.empty?
172
+
173
+ # Group includes: top-level and nested
174
+ top_level = {}
175
+ include_list.each do |inc|
176
+ parts = inc.to_s.split(".", 2)
177
+ rel_name = parts[0].to_sym
178
+ top_level[rel_name] ||= []
179
+ top_level[rel_name] << parts[1] if parts.length > 1
180
+ end
181
+
182
+ top_level.each do |rel_name, nested|
183
+ rel = relationship_definitions[rel_name]
184
+ next unless rel
185
+
186
+ klass = Object.const_get(rel[:class_name])
187
+ pk = primary_key_field || :id
188
+
189
+ case rel[:type]
190
+ when :has_one, :has_many
191
+ fk = rel[:foreign_key] || "#{name.split('::').last.downcase}_id"
192
+ pk_values = instances.map { |inst| inst.__send__(pk) }.compact.uniq
193
+ next if pk_values.empty?
194
+
195
+ placeholders = pk_values.map { "?" }.join(",")
196
+ sql = "SELECT * FROM #{klass.table_name} WHERE #{fk} IN (#{placeholders})"
197
+ results = klass.db.fetch(sql, pk_values)
198
+ related_records = results.map { |row| klass.from_hash(row) }
199
+
200
+ # Eager load nested
201
+ klass.eager_load(related_records, nested) unless nested.empty?
202
+
203
+ # Group by FK
204
+ grouped = {}
205
+ related_records.each do |record|
206
+ fk_val = record.__send__(fk.to_sym) if record.respond_to?(fk.to_sym)
207
+ (grouped[fk_val] ||= []) << record
208
+ end
209
+
210
+ instances.each do |inst|
211
+ pk_val = inst.__send__(pk)
212
+ records = grouped[pk_val] || []
213
+ if rel[:type] == :has_one
214
+ inst.instance_variable_get(:@relationship_cache)[rel_name] = records.first
215
+ else
216
+ inst.instance_variable_get(:@relationship_cache)[rel_name] = records
217
+ end
218
+ end
219
+
220
+ when :belongs_to
221
+ fk = rel[:foreign_key] || "#{rel_name}_id"
222
+ fk_values = instances.map { |inst|
223
+ inst.respond_to?(fk.to_sym) ? inst.__send__(fk.to_sym) : nil
224
+ }.compact.uniq
225
+ next if fk_values.empty?
226
+
227
+ related_pk = klass.primary_key_field || :id
228
+ placeholders = fk_values.map { "?" }.join(",")
229
+ sql = "SELECT * FROM #{klass.table_name} WHERE #{related_pk} IN (#{placeholders})"
230
+ results = klass.db.fetch(sql, fk_values)
231
+ related_records = results.map { |row| klass.from_hash(row) }
232
+
233
+ klass.eager_load(related_records, nested) unless nested.empty?
234
+
235
+ lookup = {}
236
+ related_records.each { |r| lookup[r.__send__(related_pk)] = r }
237
+
238
+ instances.each do |inst|
239
+ fk_val = inst.respond_to?(fk.to_sym) ? inst.__send__(fk.to_sym) : nil
240
+ inst.instance_variable_get(:@relationship_cache)[rel_name] = lookup[fk_val]
241
+ end
242
+ end
243
+ end
244
+ end
245
+
246
+ def where(conditions, params = [], limit: 20, offset: 0, include: nil) # -> list[Self]
247
+ sql = "SELECT * FROM #{table_name}"
248
+ if soft_delete
249
+ sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})"
250
+ else
251
+ sql += " WHERE #{conditions}"
252
+ end
253
+ results = db.fetch(sql, params, limit: limit, offset: offset)
254
+ instances = results.map { |row| from_hash(row) }
255
+ eager_load(instances, include) if include
256
+ instances
257
+ end
258
+
259
+ def all(limit: nil, offset: nil, order_by: nil, include: nil) # -> list[Self]
260
+ sql = "SELECT * FROM #{table_name}"
261
+ if soft_delete
262
+ sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0"
263
+ end
264
+ sql += " ORDER BY #{order_by}" if order_by
265
+ results = db.fetch(sql, [], limit: limit, offset: offset)
266
+ instances = results.map { |row| from_hash(row) }
267
+ eager_load(instances, include) if include
268
+ instances
269
+ end
270
+
271
+ def select(sql, params = [], limit: nil, offset: nil, include: nil) # -> list[Self]
272
+ results = db.fetch(sql, params, limit: limit, offset: offset)
273
+ instances = results.map { |row| from_hash(row) }
274
+ eager_load(instances, include) if include
275
+ instances
276
+ end
277
+
278
+ def select_one(sql, params = [], include: nil) # -> Self | nil
279
+ results = select(sql, params, limit: 1, include: include)
280
+ results.first
281
+ end
282
+
283
+ def count(conditions = nil, params = []) # -> int
284
+ sql = "SELECT COUNT(*) as cnt FROM #{table_name}"
285
+ where_parts = []
286
+ if soft_delete
287
+ where_parts << "(#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
288
+ end
289
+ where_parts << "(#{conditions})" if conditions
290
+ sql += " WHERE #{where_parts.join(' AND ')}" unless where_parts.empty?
291
+ result = db.fetch_one(sql, params)
292
+ result[:cnt].to_i
293
+ end
294
+
295
+ def create(attributes = {}) # -> Self
296
+ instance = new(attributes)
297
+ instance.save
298
+ instance
299
+ end
300
+
301
+ def find_or_fail(id) # -> Self
302
+ result = find(id)
303
+ raise "#{name} with #{primary_key_field || :id}=#{id} not found" if result.nil?
304
+ result
305
+ end
306
+
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]
331
+ sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
332
+ results = db.fetch(sql, params, limit: limit, offset: offset)
333
+ results.map { |row| from_hash(row) }
334
+ end
335
+
336
+ def create_table # -> bool
337
+ return true if db.table_exists?(table_name)
338
+
339
+ type_map = {
340
+ integer: "INTEGER",
341
+ string: "VARCHAR(255)",
342
+ text: "TEXT",
343
+ float: "REAL",
344
+ decimal: "REAL",
345
+ boolean: "INTEGER",
346
+ date: "DATE",
347
+ datetime: "DATETIME",
348
+ timestamp: "TIMESTAMP",
349
+ blob: "BLOB",
350
+ json: "TEXT"
351
+ }
352
+
353
+ col_defs = []
354
+ field_definitions.each do |name, opts|
355
+ sql_type = type_map[opts[:type]] || "TEXT"
356
+ if opts[:type] == :string && opts[:length]
357
+ sql_type = "VARCHAR(#{opts[:length]})"
358
+ end
359
+
360
+ parts = ["#{name} #{sql_type}"]
361
+ parts << "PRIMARY KEY" if opts[:primary_key]
362
+ parts << "AUTOINCREMENT" if opts[:auto_increment]
363
+ parts << "NOT NULL" if !opts[:nullable] && !opts[:primary_key]
364
+ if opts[:default] && !opts[:auto_increment]
365
+ default_val = opts[:default].is_a?(String) ? "'#{opts[:default]}'" : opts[:default]
366
+ parts << "DEFAULT #{default_val}"
367
+ end
368
+ col_defs << parts.join(" ")
369
+ end
370
+
371
+ sql = "CREATE TABLE IF NOT EXISTS #{table_name} (#{col_defs.join(', ')})"
372
+ db.execute(sql)
373
+ db.commit
374
+ true
375
+ end
376
+
377
+ def scope(name, filter_sql, params = []) # -> nil
378
+ define_singleton_method(name) do |limit: 20, offset: 0|
379
+ where(filter_sql, params)
380
+ end
381
+ end
382
+
383
+ def from_hash(hash)
384
+ instance = new
385
+ mapping_reverse = field_mapping.invert
386
+ hash.each do |key, value|
387
+ # Apply field mapping (db_col => ruby_attr)
388
+ attr_name = mapping_reverse[key.to_s] || key
389
+ setter = "#{attr_name}="
390
+ instance.__send__(setter, value) if instance.respond_to?(setter)
391
+ end
392
+ instance.instance_variable_set(:@persisted, true)
393
+ instance
394
+ end
395
+
396
+ # Find a single record by primary key. Returns instance or nil.
397
+ def find_by_id(id, include: nil) # -> Self | nil
398
+ pk = primary_key_field || :id
399
+ sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
400
+ if soft_delete
401
+ sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
402
+ 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
432
+ end
433
+
434
+ def find_by_filter(filter)
435
+ where_parts = filter.keys.map { |k| "#{k} = ?" }
436
+ sql = "SELECT * FROM #{table_name} WHERE #{where_parts.join(' AND ')}"
437
+ if soft_delete
438
+ sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
439
+ end
440
+ results = db.fetch(sql, filter.values)
441
+ results.map { |row| from_hash(row) }
442
+ end
443
+ end
444
+
445
+ def initialize(attributes = {})
446
+ @persisted = false
447
+ @errors = []
448
+ @relationship_cache = {}
449
+ attributes.each do |key, value|
450
+ setter = "#{key}="
451
+ __send__(setter, value) if respond_to?(setter)
452
+ end
453
+ # Set defaults
454
+ self.class.field_definitions.each do |name, opts|
455
+ if __send__(name).nil? && opts[:default]
456
+ __send__("#{name}=", opts[:default])
457
+ end
458
+ end
459
+ end
460
+
461
+ def save # -> Self | bool
462
+ @errors = []
463
+ @relationship_cache = {} # Clear relationship cache on save
464
+ validate_fields
465
+ return false unless @errors.empty?
466
+
467
+ data = to_db_hash(exclude_nil: true)
468
+ pk = self.class.primary_key_field || :id
469
+ pk_value = __send__(pk)
470
+
471
+ self.class.db.transaction do |db|
472
+ if @persisted && pk_value
473
+ filter = { pk => pk_value }
474
+ data.delete(pk)
475
+ # Remove mapped primary key too
476
+ mapped_pk = self.class.field_mapping[pk.to_s]
477
+ data.delete(mapped_pk.to_sym) if mapped_pk
478
+ db.update(self.class.table_name, data, filter)
479
+ else
480
+ result = db.insert(self.class.table_name, data)
481
+ if result[:last_id] && respond_to?("#{pk}=")
482
+ __send__("#{pk}=", result[:last_id])
483
+ end
484
+ @persisted = true
485
+ end
486
+ end
487
+ true
488
+ rescue => e
489
+ @errors << e.message
490
+ false
491
+ end
492
+
493
+ def delete # -> bool
494
+ pk = self.class.primary_key_field || :id
495
+ pk_value = __send__(pk)
496
+ return false unless pk_value
497
+
498
+ self.class.db.transaction do |db|
499
+ if self.class.soft_delete
500
+ db.update(
501
+ self.class.table_name,
502
+ { self.class.soft_delete_field => 1 },
503
+ { pk => pk_value }
504
+ )
505
+ else
506
+ db.delete(self.class.table_name, { pk => pk_value })
507
+ end
508
+ end
509
+ @persisted = false
510
+ true
511
+ end
512
+
513
+ def force_delete # -> bool
514
+ pk = self.class.primary_key_field || :id
515
+ pk_value = __send__(pk)
516
+ raise "Cannot delete: no primary key value" unless pk_value
517
+
518
+ self.class.db.transaction do |db|
519
+ db.delete(self.class.table_name, { pk => pk_value })
520
+ end
521
+ @persisted = false
522
+ true
523
+ end
524
+
525
+ def restore # -> bool
526
+ raise "Model does not support soft delete" unless self.class.soft_delete
527
+
528
+ pk = self.class.primary_key_field || :id
529
+ pk_value = __send__(pk)
530
+ raise "Cannot restore: no primary key value" unless pk_value
531
+
532
+ self.class.db.transaction do |db|
533
+ db.update(
534
+ self.class.table_name,
535
+ { self.class.soft_delete_field => 0 },
536
+ { pk => pk_value }
537
+ )
538
+ end
539
+ __send__("#{self.class.soft_delete_field}=", 0) if respond_to?("#{self.class.soft_delete_field}=")
540
+ true
541
+ end
542
+
543
+ def validate # -> list[str]
544
+ errors = []
545
+ self.class.field_definitions.each do |name, opts|
546
+ value = __send__(name)
547
+ if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default]
548
+ errors << "#{name} cannot be null"
549
+ end
550
+ end
551
+ errors
552
+ end
553
+
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
578
+
579
+ result = self.class.select_one(sql, params, include: include)
580
+ return false unless result
581
+
582
+ mapping_reverse = self.class.field_mapping.invert
583
+ result.to_h.each do |key, value|
584
+ attr_name = mapping_reverse[key.to_s] || key
585
+ setter = "#{attr_name}="
586
+ __send__(setter, value) if respond_to?(setter)
587
+ end
588
+ @persisted = true
589
+ true
590
+ end
591
+
592
+ def persisted?
593
+ @persisted
594
+ end
595
+
596
+ def errors
597
+ @errors
598
+ end
599
+
600
+ # Convert to hash using Ruby attribute names.
601
+ # 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
604
+ hash = {}
605
+ self.class.field_definitions.each_key do |name|
606
+ hash[name] = __send__(name)
607
+ end
608
+
609
+ if include
610
+ # Group includes: top-level and nested
611
+ top_level = {}
612
+ include.each do |inc|
613
+ parts = inc.to_s.split(".", 2)
614
+ rel_name = parts[0].to_sym
615
+ top_level[rel_name] ||= []
616
+ top_level[rel_name] << parts[1] if parts.length > 1
617
+ end
618
+
619
+ top_level.each do |rel_name, nested|
620
+ next unless self.class.relationship_definitions.key?(rel_name)
621
+ related = __send__(rel_name)
622
+ if related.nil?
623
+ hash[rel_name] = nil
624
+ 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)) }
626
+ else
627
+ hash[rel_name] = related.to_h(include: nested.empty? ? nil : nested, case: binding.local_variable_get(:case))
628
+ end
629
+ end
630
+ end
631
+
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
+ hash
643
+ end
644
+
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
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
662
+ to_h.values
663
+ end
664
+
665
+ alias to_list to_array
666
+
667
+ def to_json(include: nil, **_args) # -> str
668
+ JSON.generate(to_h(include: include))
669
+ end
670
+
671
+ def to_s
672
+ "#<#{self.class.name} #{to_h}>"
673
+ end
674
+
675
+ def select(*fields)
676
+ fields_str = fields.map(&:to_s).join(", ")
677
+ pk = self.class.primary_key_field || :id
678
+ pk_value = __send__(pk)
679
+ self.class.db.fetch_one("SELECT #{fields_str} FROM #{self.class.table_name} WHERE #{pk} = ?", [pk_value])
680
+ end
681
+
682
+ private
683
+
684
+ # Convert to hash using DB column names (with field_mapping applied)
685
+ def to_db_hash(exclude_nil: false)
686
+ hash = {}
687
+ mapping = self.class.field_mapping
688
+ self.class.field_definitions.each_key do |name|
689
+ value = __send__(name)
690
+ next if exclude_nil && value.nil?
691
+ db_col = mapping[name.to_s] || name
692
+ hash[db_col.to_sym] = value
693
+ end
694
+ hash
695
+ end
696
+
697
+ def validate_fields
698
+ self.class.field_definitions.each do |name, opts|
699
+ value = __send__(name)
700
+ if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default]
701
+ @errors << "#{name} cannot be null"
702
+ end
703
+ end
704
+ end
705
+
706
+ def load_has_one(name)
707
+ return @relationship_cache[name] if @relationship_cache.key?(name)
708
+ rel = self.class.relationship_definitions[name]
709
+ return nil unless rel
710
+
711
+ klass = Object.const_get(rel[:class_name])
712
+ pk = self.class.primary_key_field || :id
713
+ fk = rel[:foreign_key] || "#{self.class.name.split('::').last.downcase}_id"
714
+ pk_value = __send__(pk)
715
+ return nil unless pk_value
716
+
717
+ result = klass.db.fetch_one(
718
+ "SELECT * FROM #{klass.table_name} WHERE #{fk} = ?", [pk_value]
719
+ )
720
+ @relationship_cache[name] = result ? klass.from_hash(result) : nil
721
+ end
722
+
723
+ def load_has_many(name)
724
+ return @relationship_cache[name] if @relationship_cache.key?(name)
725
+ rel = self.class.relationship_definitions[name]
726
+ return [] unless rel
727
+
728
+ klass = Object.const_get(rel[:class_name])
729
+ pk = self.class.primary_key_field || :id
730
+ fk = rel[:foreign_key] || "#{self.class.name.split('::').last.downcase}_id"
731
+ pk_value = __send__(pk)
732
+ return [] unless pk_value
733
+
734
+ results = klass.db.fetch(
735
+ "SELECT * FROM #{klass.table_name} WHERE #{fk} = ?", [pk_value]
736
+ )
737
+ @relationship_cache[name] = results.map { |row| klass.from_hash(row) }
738
+ end
739
+
740
+ def load_belongs_to(name)
741
+ return @relationship_cache[name] if @relationship_cache.key?(name)
742
+ rel = self.class.relationship_definitions[name]
743
+ return nil unless rel
744
+
745
+ klass = Object.const_get(rel[:class_name])
746
+ fk = rel[:foreign_key] || "#{name}_id"
747
+ fk_value = __send__(fk.to_sym) if respond_to?(fk.to_sym)
748
+ return nil unless fk_value
749
+
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)
788
+ end
789
+ end
790
+ end