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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1 -1
- data/README.md +434 -544
- data/exe/{tina4 → tina4ruby} +1 -0
- data/lib/tina4/ai.rb +312 -0
- data/lib/tina4/auth.rb +44 -3
- data/lib/tina4/auto_crud.rb +163 -0
- data/lib/tina4/cli.rb +389 -97
- data/lib/tina4/constants.rb +46 -0
- data/lib/tina4/cors.rb +74 -0
- data/lib/tina4/database/sqlite3_adapter.rb +139 -0
- data/lib/tina4/database.rb +144 -7
- data/lib/tina4/debug.rb +4 -79
- data/lib/tina4/dev_admin.rb +1162 -0
- data/lib/tina4/dev_mailbox.rb +191 -0
- data/lib/tina4/dev_reload.rb +9 -9
- data/lib/tina4/drivers/firebird_driver.rb +19 -3
- data/lib/tina4/drivers/mssql_driver.rb +3 -3
- data/lib/tina4/drivers/mysql_driver.rb +4 -4
- data/lib/tina4/drivers/postgres_driver.rb +9 -2
- data/lib/tina4/drivers/sqlite_driver.rb +1 -1
- data/lib/tina4/env.rb +42 -2
- data/lib/tina4/error_overlay.rb +252 -0
- data/lib/tina4/events.rb +90 -0
- data/lib/tina4/field_types.rb +4 -0
- data/lib/tina4/frond.rb +1497 -0
- data/lib/tina4/gallery/auth/meta.json +1 -0
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
- data/lib/tina4/gallery/database/meta.json +1 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
- data/lib/tina4/gallery/error-overlay/meta.json +1 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
- data/lib/tina4/gallery/orm/meta.json +1 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
- data/lib/tina4/gallery/queue/meta.json +1 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -0
- data/lib/tina4/gallery/rest-api/meta.json +1 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
- data/lib/tina4/gallery/templates/meta.json +1 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
- data/lib/tina4/health.rb +39 -0
- data/lib/tina4/html_element.rb +148 -0
- data/lib/tina4/localization.rb +2 -2
- data/lib/tina4/log.rb +203 -0
- data/lib/tina4/messenger.rb +562 -0
- data/lib/tina4/migration.rb +132 -29
- data/lib/tina4/orm.rb +463 -35
- data/lib/tina4/public/css/tina4.css +178 -1
- data/lib/tina4/public/css/tina4.min.css +1 -2
- data/lib/tina4/public/favicon.ico +0 -0
- data/lib/tina4/public/images/logo.svg +5 -0
- data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
- data/lib/tina4/public/js/frond.min.js +420 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
- data/lib/tina4/public/js/tina4.min.js +93 -0
- data/lib/tina4/public/swagger/index.html +90 -0
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
- data/lib/tina4/queue.rb +162 -6
- data/lib/tina4/queue_backends/lite_backend.rb +88 -0
- data/lib/tina4/rack_app.rb +331 -27
- data/lib/tina4/rate_limiter.rb +123 -0
- data/lib/tina4/request.rb +61 -15
- data/lib/tina4/response.rb +54 -24
- data/lib/tina4/response_cache.rb +551 -0
- data/lib/tina4/router.rb +90 -15
- data/lib/tina4/scss_compiler.rb +2 -2
- data/lib/tina4/seeder.rb +56 -61
- data/lib/tina4/service_runner.rb +303 -0
- data/lib/tina4/session.rb +85 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
- data/lib/tina4/shutdown.rb +84 -0
- data/lib/tina4/sql_translation.rb +295 -0
- data/lib/tina4/template.rb +36 -6
- data/lib/tina4/templates/base.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +14 -0
- data/lib/tina4/templates/errors/401.twig +9 -0
- data/lib/tina4/templates/errors/403.twig +22 -15
- data/lib/tina4/templates/errors/404.twig +22 -15
- data/lib/tina4/templates/errors/500.twig +31 -15
- data/lib/tina4/templates/errors/502.twig +9 -0
- data/lib/tina4/templates/errors/503.twig +12 -0
- data/lib/tina4/templates/errors/base.twig +37 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -18
- data/lib/tina4.rb +118 -21
- metadata +68 -8
- data/lib/tina4/public/js/tina4.js +0 -134
- 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
30
|
-
results.
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
67
|
-
|
|
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 =
|
|
357
|
+
data = to_db_hash(exclude_nil: true)
|
|
78
358
|
pk = self.class.primary_key_field || :id
|
|
79
|
-
pk_value =
|
|
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
|
-
|
|
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 =
|
|
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 ||=
|
|
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(
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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} #{
|
|
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 =
|
|
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 =
|
|
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
|