tina4ruby 0.5.2 → 3.2.1

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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +434 -544
  4. data/exe/{tina4 → tina4ruby} +1 -0
  5. data/lib/tina4/ai.rb +312 -0
  6. data/lib/tina4/auth.rb +44 -3
  7. data/lib/tina4/auto_crud.rb +163 -0
  8. data/lib/tina4/cli.rb +389 -97
  9. data/lib/tina4/constants.rb +46 -0
  10. data/lib/tina4/cors.rb +74 -0
  11. data/lib/tina4/database/sqlite3_adapter.rb +139 -0
  12. data/lib/tina4/database.rb +144 -7
  13. data/lib/tina4/debug.rb +4 -79
  14. data/lib/tina4/dev_admin.rb +1162 -0
  15. data/lib/tina4/dev_mailbox.rb +191 -0
  16. data/lib/tina4/dev_reload.rb +9 -9
  17. data/lib/tina4/drivers/firebird_driver.rb +19 -3
  18. data/lib/tina4/drivers/mssql_driver.rb +3 -3
  19. data/lib/tina4/drivers/mysql_driver.rb +4 -4
  20. data/lib/tina4/drivers/postgres_driver.rb +9 -2
  21. data/lib/tina4/drivers/sqlite_driver.rb +1 -1
  22. data/lib/tina4/env.rb +42 -2
  23. data/lib/tina4/error_overlay.rb +252 -0
  24. data/lib/tina4/events.rb +90 -0
  25. data/lib/tina4/field_types.rb +4 -0
  26. data/lib/tina4/frond.rb +1497 -0
  27. data/lib/tina4/gallery/auth/meta.json +1 -0
  28. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
  29. data/lib/tina4/gallery/database/meta.json +1 -0
  30. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
  31. data/lib/tina4/gallery/error-overlay/meta.json +1 -0
  32. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
  33. data/lib/tina4/gallery/orm/meta.json +1 -0
  34. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
  35. data/lib/tina4/gallery/queue/meta.json +1 -0
  36. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -0
  37. data/lib/tina4/gallery/rest-api/meta.json +1 -0
  38. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
  39. data/lib/tina4/gallery/templates/meta.json +1 -0
  40. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
  41. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
  42. data/lib/tina4/health.rb +39 -0
  43. data/lib/tina4/html_element.rb +148 -0
  44. data/lib/tina4/localization.rb +2 -2
  45. data/lib/tina4/log.rb +203 -0
  46. data/lib/tina4/messenger.rb +562 -0
  47. data/lib/tina4/migration.rb +132 -29
  48. data/lib/tina4/orm.rb +463 -35
  49. data/lib/tina4/public/css/tina4.css +178 -1
  50. data/lib/tina4/public/css/tina4.min.css +1 -2
  51. data/lib/tina4/public/favicon.ico +0 -0
  52. data/lib/tina4/public/images/logo.svg +5 -0
  53. data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
  54. data/lib/tina4/public/js/frond.min.js +420 -0
  55. data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
  56. data/lib/tina4/public/js/tina4.min.js +93 -0
  57. data/lib/tina4/public/swagger/index.html +90 -0
  58. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
  59. data/lib/tina4/queue.rb +162 -6
  60. data/lib/tina4/queue_backends/lite_backend.rb +88 -0
  61. data/lib/tina4/rack_app.rb +331 -27
  62. data/lib/tina4/rate_limiter.rb +123 -0
  63. data/lib/tina4/request.rb +61 -15
  64. data/lib/tina4/response.rb +54 -24
  65. data/lib/tina4/response_cache.rb +551 -0
  66. data/lib/tina4/router.rb +90 -15
  67. data/lib/tina4/scss_compiler.rb +2 -2
  68. data/lib/tina4/seeder.rb +56 -61
  69. data/lib/tina4/service_runner.rb +303 -0
  70. data/lib/tina4/session.rb +85 -0
  71. data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
  72. data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
  73. data/lib/tina4/shutdown.rb +84 -0
  74. data/lib/tina4/sql_translation.rb +295 -0
  75. data/lib/tina4/template.rb +36 -6
  76. data/lib/tina4/templates/base.twig +2 -2
  77. data/lib/tina4/templates/errors/302.twig +14 -0
  78. data/lib/tina4/templates/errors/401.twig +9 -0
  79. data/lib/tina4/templates/errors/403.twig +22 -15
  80. data/lib/tina4/templates/errors/404.twig +22 -15
  81. data/lib/tina4/templates/errors/500.twig +31 -15
  82. data/lib/tina4/templates/errors/502.twig +9 -0
  83. data/lib/tina4/templates/errors/503.twig +12 -0
  84. data/lib/tina4/templates/errors/base.twig +37 -0
  85. data/lib/tina4/version.rb +1 -1
  86. data/lib/tina4/webserver.rb +28 -18
  87. data/lib/tina4.rb +118 -21
  88. metadata +68 -8
  89. data/lib/tina4/public/js/tina4.js +0 -134
  90. data/lib/tina4/public/js/tina4helper.js +0 -387
data/lib/tina4/orm.rb CHANGED
@@ -7,32 +7,226 @@ module Tina4
7
7
 
8
8
  class << self
9
9
  def db
10
- Tina4.database
10
+ @db || Tina4.database
11
11
  end
12
12
 
13
- def find(id)
14
- pk = primary_key_field || :id
15
- result = db.fetch_one("SELECT * FROM #{table_name} WHERE #{pk} = ?", [id])
16
- return nil unless result
17
- from_hash(result)
13
+ # Per-model database binding
14
+ def db=(database)
15
+ @db = database
18
16
  end
19
17
 
20
- def where(conditions, params = [])
21
- sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
18
+ # Soft delete configuration
19
+ def soft_delete
20
+ @soft_delete || false
21
+ end
22
+
23
+ def soft_delete=(val)
24
+ @soft_delete = val
25
+ end
26
+
27
+ def soft_delete_field
28
+ @soft_delete_field || :is_deleted
29
+ end
30
+
31
+ def soft_delete_field=(val)
32
+ @soft_delete_field = val
33
+ end
34
+
35
+ # Field mapping: { 'db_column' => 'ruby_attribute' }
36
+ def field_mapping
37
+ @field_mapping || {}
38
+ end
39
+
40
+ def field_mapping=(map)
41
+ @field_mapping = map
42
+ end
43
+
44
+ # Relationship definitions
45
+ def relationship_definitions
46
+ @relationship_definitions ||= {}
47
+ end
48
+
49
+ # has_one :profile, class_name: "Profile", foreign_key: "user_id"
50
+ def has_one(name, class_name: nil, foreign_key: nil)
51
+ relationship_definitions[name] = {
52
+ type: :has_one,
53
+ class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
54
+ foreign_key: foreign_key
55
+ }
56
+
57
+ define_method(name) do
58
+ load_has_one(name)
59
+ end
60
+ end
61
+
62
+ # has_many :posts, class_name: "Post", foreign_key: "user_id"
63
+ def has_many(name, class_name: nil, foreign_key: nil)
64
+ relationship_definitions[name] = {
65
+ type: :has_many,
66
+ class_name: class_name || name.to_s.sub(/s$/, "").split("_").map(&:capitalize).join,
67
+ foreign_key: foreign_key
68
+ }
69
+
70
+ define_method(name) do
71
+ load_has_many(name)
72
+ end
73
+ end
74
+
75
+ # belongs_to :user, class_name: "User", foreign_key: "user_id"
76
+ def belongs_to(name, class_name: nil, foreign_key: nil)
77
+ relationship_definitions[name] = {
78
+ type: :belongs_to,
79
+ class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
80
+ foreign_key: foreign_key || "#{name}_id"
81
+ }
82
+
83
+ define_method(name) do
84
+ load_belongs_to(name)
85
+ end
86
+ end
87
+
88
+ def find(id_or_filter = nil, filter = nil, **kwargs)
89
+ include_list = kwargs.delete(:include)
90
+
91
+ # find(id) — find by primary key
92
+ # find(filter_hash) — find by criteria
93
+ # find(name: "Alice") — keyword args as filter hash
94
+ result = if id_or_filter.is_a?(Hash)
95
+ find_by_filter(id_or_filter)
96
+ elsif filter.is_a?(Hash)
97
+ find_by_filter(filter)
98
+ elsif !kwargs.empty?
99
+ find_by_filter(kwargs)
100
+ else
101
+ find_by_id(id_or_filter)
102
+ end
103
+
104
+ if include_list && result
105
+ instances = result.is_a?(Array) ? result : [result]
106
+ eager_load(instances, include_list)
107
+ end
108
+ result
109
+ end
110
+
111
+ # Eager load relationships for a collection of instances (prevents N+1).
112
+ # include is an array of relationship names, supporting dot notation for nesting.
113
+ def eager_load(instances, include_list)
114
+ return if instances.nil? || instances.empty?
115
+
116
+ # Group includes: top-level and nested
117
+ top_level = {}
118
+ include_list.each do |inc|
119
+ parts = inc.to_s.split(".", 2)
120
+ rel_name = parts[0].to_sym
121
+ top_level[rel_name] ||= []
122
+ top_level[rel_name] << parts[1] if parts.length > 1
123
+ end
124
+
125
+ top_level.each do |rel_name, nested|
126
+ rel = relationship_definitions[rel_name]
127
+ next unless rel
128
+
129
+ klass = Object.const_get(rel[:class_name])
130
+ pk = primary_key_field || :id
131
+
132
+ case rel[:type]
133
+ when :has_one, :has_many
134
+ fk = rel[:foreign_key] || "#{name.split('::').last.downcase}_id"
135
+ pk_values = instances.map { |inst| inst.__send__(pk) }.compact.uniq
136
+ next if pk_values.empty?
137
+
138
+ placeholders = pk_values.map { "?" }.join(",")
139
+ sql = "SELECT * FROM #{klass.table_name} WHERE #{fk} IN (#{placeholders})"
140
+ results = klass.db.fetch(sql, pk_values)
141
+ related_records = results.map { |row| klass.from_hash(row) }
142
+
143
+ # Eager load nested
144
+ klass.eager_load(related_records, nested) unless nested.empty?
145
+
146
+ # Group by FK
147
+ grouped = {}
148
+ related_records.each do |record|
149
+ fk_val = record.__send__(fk.to_sym) if record.respond_to?(fk.to_sym)
150
+ (grouped[fk_val] ||= []) << record
151
+ end
152
+
153
+ instances.each do |inst|
154
+ pk_val = inst.__send__(pk)
155
+ records = grouped[pk_val] || []
156
+ if rel[:type] == :has_one
157
+ inst.instance_variable_get(:@relationship_cache)[rel_name] = records.first
158
+ else
159
+ inst.instance_variable_get(:@relationship_cache)[rel_name] = records
160
+ end
161
+ end
162
+
163
+ when :belongs_to
164
+ fk = rel[:foreign_key] || "#{rel_name}_id"
165
+ fk_values = instances.map { |inst|
166
+ inst.respond_to?(fk.to_sym) ? inst.__send__(fk.to_sym) : nil
167
+ }.compact.uniq
168
+ next if fk_values.empty?
169
+
170
+ related_pk = klass.primary_key_field || :id
171
+ placeholders = fk_values.map { "?" }.join(",")
172
+ sql = "SELECT * FROM #{klass.table_name} WHERE #{related_pk} IN (#{placeholders})"
173
+ results = klass.db.fetch(sql, fk_values)
174
+ related_records = results.map { |row| klass.from_hash(row) }
175
+
176
+ klass.eager_load(related_records, nested) unless nested.empty?
177
+
178
+ lookup = {}
179
+ related_records.each { |r| lookup[r.__send__(related_pk)] = r }
180
+
181
+ instances.each do |inst|
182
+ fk_val = inst.respond_to?(fk.to_sym) ? inst.__send__(fk.to_sym) : nil
183
+ inst.instance_variable_get(:@relationship_cache)[rel_name] = lookup[fk_val]
184
+ end
185
+ end
186
+ end
187
+ end
188
+
189
+ def where(conditions, params = [], include: nil)
190
+ sql = "SELECT * FROM #{table_name}"
191
+ if soft_delete
192
+ sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})"
193
+ else
194
+ sql += " WHERE #{conditions}"
195
+ end
22
196
  results = db.fetch(sql, params)
23
- results.map { |row| from_hash(row) }
197
+ instances = results.map { |row| from_hash(row) }
198
+ eager_load(instances, include) if include
199
+ instances
24
200
  end
25
201
 
26
- def all(limit: nil, skip: nil, order_by: nil)
202
+ def all(limit: nil, offset: nil, skip: nil, order_by: nil, include: nil)
27
203
  sql = "SELECT * FROM #{table_name}"
204
+ if soft_delete
205
+ sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0"
206
+ end
28
207
  sql += " ORDER BY #{order_by}" if order_by
29
- results = db.fetch(sql, [], limit: limit, skip: skip)
30
- results.map { |row| from_hash(row) }
208
+ effective_offset = offset || skip
209
+ results = db.fetch(sql, [], limit: limit, skip: effective_offset)
210
+ instances = results.map { |row| from_hash(row) }
211
+ eager_load(instances, include) if include
212
+ instances
213
+ end
214
+
215
+ def select(sql, params = [], limit: nil, skip: nil, include: nil)
216
+ results = db.fetch(sql, params, limit: limit, skip: skip)
217
+ instances = results.map { |row| from_hash(row) }
218
+ eager_load(instances, include) if include
219
+ instances
31
220
  end
32
221
 
33
222
  def count(conditions = nil, params = [])
34
223
  sql = "SELECT COUNT(*) as cnt FROM #{table_name}"
35
- sql += " WHERE #{conditions}" if conditions
224
+ where_parts = []
225
+ if soft_delete
226
+ where_parts << "(#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
227
+ end
228
+ where_parts << "(#{conditions})" if conditions
229
+ sql += " WHERE #{where_parts.join(' AND ')}" unless where_parts.empty?
36
230
  result = db.fetch_one(sql, params)
37
231
  result[:cnt].to_i
38
232
  end
@@ -43,49 +237,138 @@ module Tina4
43
237
  instance
44
238
  end
45
239
 
240
+ def find_or_fail(id)
241
+ result = find(id)
242
+ raise "#{name} with #{primary_key_field || :id}=#{id} not found" if result.nil?
243
+ result
244
+ end
245
+
246
+ def with_trashed(conditions = "1=1", params = [], limit: 20, skip: 0)
247
+ sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
248
+ results = db.fetch(sql, params, limit: limit, skip: skip)
249
+ results.map { |row| from_hash(row) }
250
+ end
251
+
252
+ def create_table
253
+ return true if db.table_exists?(table_name)
254
+
255
+ type_map = {
256
+ integer: "INTEGER",
257
+ string: "VARCHAR(255)",
258
+ text: "TEXT",
259
+ float: "REAL",
260
+ decimal: "REAL",
261
+ boolean: "INTEGER",
262
+ date: "DATE",
263
+ datetime: "DATETIME",
264
+ timestamp: "TIMESTAMP",
265
+ blob: "BLOB",
266
+ json: "TEXT"
267
+ }
268
+
269
+ col_defs = []
270
+ field_definitions.each do |name, opts|
271
+ sql_type = type_map[opts[:type]] || "TEXT"
272
+ if opts[:type] == :string && opts[:length]
273
+ sql_type = "VARCHAR(#{opts[:length]})"
274
+ end
275
+
276
+ parts = ["#{name} #{sql_type}"]
277
+ parts << "PRIMARY KEY" if opts[:primary_key]
278
+ parts << "AUTOINCREMENT" if opts[:auto_increment]
279
+ parts << "NOT NULL" if !opts[:nullable] && !opts[:primary_key]
280
+ if opts[:default] && !opts[:auto_increment]
281
+ default_val = opts[:default].is_a?(String) ? "'#{opts[:default]}'" : opts[:default]
282
+ parts << "DEFAULT #{default_val}"
283
+ end
284
+ col_defs << parts.join(" ")
285
+ end
286
+
287
+ sql = "CREATE TABLE IF NOT EXISTS #{table_name} (#{col_defs.join(', ')})"
288
+ db.execute(sql)
289
+ true
290
+ end
291
+
292
+ def scope(name, filter_sql, params = [])
293
+ define_singleton_method(name) do |limit: 20, skip: 0|
294
+ where(filter_sql, params)
295
+ end
296
+ end
297
+
46
298
  def from_hash(hash)
47
299
  instance = new
300
+ mapping_reverse = field_mapping.invert
48
301
  hash.each do |key, value|
49
- setter = "#{key}="
50
- instance.send(setter, value) if instance.respond_to?(setter)
302
+ # Apply field mapping (db_col => ruby_attr)
303
+ attr_name = mapping_reverse[key.to_s] || key
304
+ setter = "#{attr_name}="
305
+ instance.__send__(setter, value) if instance.respond_to?(setter)
51
306
  end
52
307
  instance.instance_variable_set(:@persisted, true)
53
308
  instance
54
309
  end
310
+
311
+ private
312
+
313
+ def find_by_id(id)
314
+ pk = primary_key_field || :id
315
+ sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
316
+ if soft_delete
317
+ sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
318
+ end
319
+ result = db.fetch_one(sql, [id])
320
+ return nil unless result
321
+ from_hash(result)
322
+ end
323
+
324
+ def find_by_filter(filter)
325
+ where_parts = filter.keys.map { |k| "#{k} = ?" }
326
+ sql = "SELECT * FROM #{table_name} WHERE #{where_parts.join(' AND ')}"
327
+ if soft_delete
328
+ sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
329
+ end
330
+ results = db.fetch(sql, filter.values)
331
+ results.map { |row| from_hash(row) }
332
+ end
55
333
  end
56
334
 
57
335
  def initialize(attributes = {})
58
336
  @persisted = false
59
337
  @errors = []
338
+ @relationship_cache = {}
60
339
  attributes.each do |key, value|
61
340
  setter = "#{key}="
62
- send(setter, value) if respond_to?(setter)
341
+ __send__(setter, value) if respond_to?(setter)
63
342
  end
64
343
  # Set defaults
65
344
  self.class.field_definitions.each do |name, opts|
66
- if send(name).nil? && opts[:default]
67
- send("#{name}=", opts[:default])
345
+ if __send__(name).nil? && opts[:default]
346
+ __send__("#{name}=", opts[:default])
68
347
  end
69
348
  end
70
349
  end
71
350
 
72
351
  def save
73
352
  @errors = []
353
+ @relationship_cache = {} # Clear relationship cache on save
74
354
  validate_fields
75
355
  return false unless @errors.empty?
76
356
 
77
- data = to_hash(exclude_nil: true)
357
+ data = to_db_hash(exclude_nil: true)
78
358
  pk = self.class.primary_key_field || :id
79
- pk_value = send(pk)
359
+ pk_value = __send__(pk)
80
360
 
81
361
  if @persisted && pk_value
82
362
  filter = { pk => pk_value }
83
363
  data.delete(pk)
364
+ # Remove mapped primary key too
365
+ mapped_pk = self.class.field_mapping[pk.to_s]
366
+ data.delete(mapped_pk.to_sym) if mapped_pk
84
367
  self.class.db.update(self.class.table_name, data, filter)
85
368
  else
86
369
  result = self.class.db.insert(self.class.table_name, data)
87
370
  if result[:last_id] && respond_to?("#{pk}=")
88
- send("#{pk}=", result[:last_id])
371
+ __send__("#{pk}=", result[:last_id])
89
372
  end
90
373
  @persisted = true
91
374
  end
@@ -97,25 +380,76 @@ module Tina4
97
380
 
98
381
  def delete
99
382
  pk = self.class.primary_key_field || :id
100
- pk_value = send(pk)
383
+ pk_value = __send__(pk)
101
384
  return false unless pk_value
102
385
 
386
+ if self.class.soft_delete
387
+ # Soft delete: set the flag
388
+ self.class.db.update(
389
+ self.class.table_name,
390
+ { self.class.soft_delete_field => 1 },
391
+ { pk => pk_value }
392
+ )
393
+ else
394
+ self.class.db.delete(self.class.table_name, { pk => pk_value })
395
+ end
396
+ @persisted = false
397
+ true
398
+ end
399
+
400
+ def force_delete
401
+ pk = self.class.primary_key_field || :id
402
+ pk_value = __send__(pk)
403
+ raise "Cannot delete: no primary key value" unless pk_value
404
+
103
405
  self.class.db.delete(self.class.table_name, { pk => pk_value })
104
406
  @persisted = false
105
407
  true
106
408
  end
107
409
 
410
+ def restore
411
+ raise "Model does not support soft delete" unless self.class.soft_delete
412
+
413
+ pk = self.class.primary_key_field || :id
414
+ pk_value = __send__(pk)
415
+ raise "Cannot restore: no primary key value" unless pk_value
416
+
417
+ self.class.db.update(
418
+ self.class.table_name,
419
+ { self.class.soft_delete_field => 0 },
420
+ { pk => pk_value }
421
+ )
422
+ __send__("#{self.class.soft_delete_field}=", 0) if respond_to?("#{self.class.soft_delete_field}=")
423
+ true
424
+ end
425
+
426
+ def validate
427
+ errors = []
428
+ self.class.field_definitions.each do |name, opts|
429
+ value = __send__(name)
430
+ if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default]
431
+ errors << "#{name} cannot be null"
432
+ end
433
+ end
434
+ errors
435
+ end
436
+
108
437
  def load(id = nil)
109
438
  pk = self.class.primary_key_field || :id
110
- id ||= send(pk)
439
+ id ||= __send__(pk)
111
440
  return false unless id
441
+ @relationship_cache = {} # Clear relationship cache on reload
112
442
 
113
- result = self.class.db.fetch_one("SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id])
443
+ result = self.class.db.fetch_one(
444
+ "SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
445
+ )
114
446
  return false unless result
115
447
 
448
+ mapping_reverse = self.class.field_mapping.invert
116
449
  result.each do |key, value|
117
- setter = "#{key}="
118
- send(setter, value) if respond_to?(setter)
450
+ attr_name = mapping_reverse[key.to_s] || key
451
+ setter = "#{attr_name}="
452
+ __send__(setter, value) if respond_to?(setter)
119
453
  end
120
454
  @persisted = true
121
455
  true
@@ -129,40 +463,134 @@ module Tina4
129
463
  @errors
130
464
  end
131
465
 
132
- def to_hash(exclude_nil: false)
466
+ # Convert to hash using Ruby attribute names.
467
+ # Optionally include relationships via the include keyword.
468
+ def to_h(include: nil)
133
469
  hash = {}
134
470
  self.class.field_definitions.each_key do |name|
135
- value = send(name)
136
- next if exclude_nil && value.nil?
137
- hash[name] = value
471
+ hash[name] = __send__(name)
472
+ end
473
+
474
+ if include
475
+ # Group includes: top-level and nested
476
+ top_level = {}
477
+ include.each do |inc|
478
+ parts = inc.to_s.split(".", 2)
479
+ rel_name = parts[0].to_sym
480
+ top_level[rel_name] ||= []
481
+ top_level[rel_name] << parts[1] if parts.length > 1
482
+ end
483
+
484
+ top_level.each do |rel_name, nested|
485
+ next unless self.class.relationship_definitions.key?(rel_name)
486
+ related = __send__(rel_name)
487
+ if related.nil?
488
+ hash[rel_name] = nil
489
+ elsif related.is_a?(Array)
490
+ hash[rel_name] = related.map { |r| r.to_h(include: nested.empty? ? nil : nested) }
491
+ else
492
+ hash[rel_name] = related.to_h(include: nested.empty? ? nil : nested)
493
+ end
494
+ end
138
495
  end
496
+
139
497
  hash
140
498
  end
141
499
 
142
- def to_json(*_args)
143
- JSON.generate(to_hash)
500
+ alias to_hash to_h
501
+ alias to_dict to_h
502
+ alias to_object to_h
503
+
504
+ def to_array
505
+ to_h.values
506
+ end
507
+
508
+ alias to_list to_array
509
+
510
+ def to_json(include: nil, **_args)
511
+ JSON.generate(to_h(include: include))
144
512
  end
145
513
 
146
514
  def to_s
147
- "#<#{self.class.name} #{to_hash}>"
515
+ "#<#{self.class.name} #{to_h}>"
148
516
  end
149
517
 
150
518
  def select(*fields)
151
519
  fields_str = fields.map(&:to_s).join(", ")
152
520
  pk = self.class.primary_key_field || :id
153
- pk_value = send(pk)
521
+ pk_value = __send__(pk)
154
522
  self.class.db.fetch_one("SELECT #{fields_str} FROM #{self.class.table_name} WHERE #{pk} = ?", [pk_value])
155
523
  end
156
524
 
157
525
  private
158
526
 
527
+ # Convert to hash using DB column names (with field_mapping applied)
528
+ def to_db_hash(exclude_nil: false)
529
+ hash = {}
530
+ mapping = self.class.field_mapping
531
+ self.class.field_definitions.each_key do |name|
532
+ value = __send__(name)
533
+ next if exclude_nil && value.nil?
534
+ db_col = mapping[name.to_s] || name
535
+ hash[db_col.to_sym] = value
536
+ end
537
+ hash
538
+ end
539
+
159
540
  def validate_fields
160
541
  self.class.field_definitions.each do |name, opts|
161
- value = send(name)
542
+ value = __send__(name)
162
543
  if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default]
163
544
  @errors << "#{name} cannot be null"
164
545
  end
165
546
  end
166
547
  end
548
+
549
+ def load_has_one(name)
550
+ return @relationship_cache[name] if @relationship_cache.key?(name)
551
+ rel = self.class.relationship_definitions[name]
552
+ return nil unless rel
553
+
554
+ klass = Object.const_get(rel[:class_name])
555
+ pk = self.class.primary_key_field || :id
556
+ fk = rel[:foreign_key] || "#{self.class.name.split('::').last.downcase}_id"
557
+ pk_value = __send__(pk)
558
+ return nil unless pk_value
559
+
560
+ result = klass.db.fetch_one(
561
+ "SELECT * FROM #{klass.table_name} WHERE #{fk} = ?", [pk_value]
562
+ )
563
+ @relationship_cache[name] = result ? klass.from_hash(result) : nil
564
+ end
565
+
566
+ def load_has_many(name)
567
+ return @relationship_cache[name] if @relationship_cache.key?(name)
568
+ rel = self.class.relationship_definitions[name]
569
+ return [] unless rel
570
+
571
+ klass = Object.const_get(rel[:class_name])
572
+ pk = self.class.primary_key_field || :id
573
+ fk = rel[:foreign_key] || "#{self.class.name.split('::').last.downcase}_id"
574
+ pk_value = __send__(pk)
575
+ return [] unless pk_value
576
+
577
+ results = klass.db.fetch(
578
+ "SELECT * FROM #{klass.table_name} WHERE #{fk} = ?", [pk_value]
579
+ )
580
+ @relationship_cache[name] = results.map { |row| klass.from_hash(row) }
581
+ end
582
+
583
+ def load_belongs_to(name)
584
+ return @relationship_cache[name] if @relationship_cache.key?(name)
585
+ rel = self.class.relationship_definitions[name]
586
+ return nil unless rel
587
+
588
+ klass = Object.const_get(rel[:class_name])
589
+ fk = rel[:foreign_key] || "#{name}_id"
590
+ fk_value = __send__(fk.to_sym) if respond_to?(fk.to_sym)
591
+ return nil unless fk_value
592
+
593
+ @relationship_cache[name] = klass.find(fk_value)
594
+ end
167
595
  end
168
596
  end