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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +935 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -110
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -106
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2025 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +696 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -255
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +458 -458
- data/lib/tina4ruby.rb +4 -4
- 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
|