tina4ruby 0.5.2 → 3.0.0
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 +360 -559
- 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 +242 -77
- 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 +43 -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 +1336 -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 +27 -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 +484 -0
- data/lib/tina4/migration.rb +132 -29
- data/lib/tina4/orm.rb +337 -31
- 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 +40 -4
- data/lib/tina4/queue_backends/lite_backend.rb +88 -0
- data/lib/tina4/rack_app.rb +314 -23
- 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 +134 -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 +57 -21
- metadata +51 -19
- 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,131 @@ 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
|
-
|
|
17
|
-
|
|
13
|
+
# Per-model database binding
|
|
14
|
+
def db=(database)
|
|
15
|
+
@db = database
|
|
16
|
+
end
|
|
17
|
+
|
|
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)
|
|
89
|
+
# find(id) — find by primary key
|
|
90
|
+
# find(filter_hash) — find by criteria
|
|
91
|
+
if id_or_filter.is_a?(Hash)
|
|
92
|
+
find_by_filter(id_or_filter)
|
|
93
|
+
elsif filter.is_a?(Hash)
|
|
94
|
+
find_by_filter(filter)
|
|
95
|
+
else
|
|
96
|
+
find_by_id(id_or_filter)
|
|
97
|
+
end
|
|
18
98
|
end
|
|
19
99
|
|
|
20
100
|
def where(conditions, params = [])
|
|
21
|
-
sql = "SELECT * FROM #{table_name}
|
|
101
|
+
sql = "SELECT * FROM #{table_name}"
|
|
102
|
+
if soft_delete
|
|
103
|
+
sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})"
|
|
104
|
+
else
|
|
105
|
+
sql += " WHERE #{conditions}"
|
|
106
|
+
end
|
|
22
107
|
results = db.fetch(sql, params)
|
|
23
108
|
results.map { |row| from_hash(row) }
|
|
24
109
|
end
|
|
25
110
|
|
|
26
|
-
def all(limit: nil, skip: nil, order_by: nil)
|
|
111
|
+
def all(limit: nil, offset: nil, skip: nil, order_by: nil)
|
|
27
112
|
sql = "SELECT * FROM #{table_name}"
|
|
113
|
+
if soft_delete
|
|
114
|
+
sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0"
|
|
115
|
+
end
|
|
28
116
|
sql += " ORDER BY #{order_by}" if order_by
|
|
29
|
-
|
|
117
|
+
effective_offset = offset || skip
|
|
118
|
+
results = db.fetch(sql, [], limit: limit, skip: effective_offset)
|
|
119
|
+
results.map { |row| from_hash(row) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def select(sql, params = [], limit: nil, skip: nil)
|
|
123
|
+
results = db.fetch(sql, params, limit: limit, skip: skip)
|
|
30
124
|
results.map { |row| from_hash(row) }
|
|
31
125
|
end
|
|
32
126
|
|
|
33
127
|
def count(conditions = nil, params = [])
|
|
34
128
|
sql = "SELECT COUNT(*) as cnt FROM #{table_name}"
|
|
35
|
-
|
|
129
|
+
where_parts = []
|
|
130
|
+
if soft_delete
|
|
131
|
+
where_parts << "(#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
|
|
132
|
+
end
|
|
133
|
+
where_parts << "(#{conditions})" if conditions
|
|
134
|
+
sql += " WHERE #{where_parts.join(' AND ')}" unless where_parts.empty?
|
|
36
135
|
result = db.fetch_one(sql, params)
|
|
37
136
|
result[:cnt].to_i
|
|
38
137
|
end
|
|
@@ -43,28 +142,113 @@ module Tina4
|
|
|
43
142
|
instance
|
|
44
143
|
end
|
|
45
144
|
|
|
145
|
+
def find_or_fail(id)
|
|
146
|
+
result = find(id)
|
|
147
|
+
raise "#{name} with #{primary_key_field || :id}=#{id} not found" if result.nil?
|
|
148
|
+
result
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def with_trashed(conditions = "1=1", params = [], limit: 20, skip: 0)
|
|
152
|
+
sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
|
|
153
|
+
results = db.fetch(sql, params, limit: limit, skip: skip)
|
|
154
|
+
results.map { |row| from_hash(row) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def create_table
|
|
158
|
+
return true if db.table_exists?(table_name)
|
|
159
|
+
|
|
160
|
+
type_map = {
|
|
161
|
+
integer: "INTEGER",
|
|
162
|
+
string: "VARCHAR(255)",
|
|
163
|
+
text: "TEXT",
|
|
164
|
+
float: "REAL",
|
|
165
|
+
decimal: "REAL",
|
|
166
|
+
boolean: "INTEGER",
|
|
167
|
+
date: "DATE",
|
|
168
|
+
datetime: "DATETIME",
|
|
169
|
+
timestamp: "TIMESTAMP",
|
|
170
|
+
blob: "BLOB",
|
|
171
|
+
json: "TEXT"
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
col_defs = []
|
|
175
|
+
field_definitions.each do |name, opts|
|
|
176
|
+
sql_type = type_map[opts[:type]] || "TEXT"
|
|
177
|
+
if opts[:type] == :string && opts[:length]
|
|
178
|
+
sql_type = "VARCHAR(#{opts[:length]})"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
parts = ["#{name} #{sql_type}"]
|
|
182
|
+
parts << "PRIMARY KEY" if opts[:primary_key]
|
|
183
|
+
parts << "AUTOINCREMENT" if opts[:auto_increment]
|
|
184
|
+
parts << "NOT NULL" if !opts[:nullable] && !opts[:primary_key]
|
|
185
|
+
if opts[:default] && !opts[:auto_increment]
|
|
186
|
+
default_val = opts[:default].is_a?(String) ? "'#{opts[:default]}'" : opts[:default]
|
|
187
|
+
parts << "DEFAULT #{default_val}"
|
|
188
|
+
end
|
|
189
|
+
col_defs << parts.join(" ")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
sql = "CREATE TABLE IF NOT EXISTS #{table_name} (#{col_defs.join(', ')})"
|
|
193
|
+
db.execute(sql)
|
|
194
|
+
true
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def scope(name, filter_sql, params = [])
|
|
198
|
+
define_singleton_method(name) do |limit: 20, skip: 0|
|
|
199
|
+
where(filter_sql, params)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
46
203
|
def from_hash(hash)
|
|
47
204
|
instance = new
|
|
205
|
+
mapping_reverse = field_mapping.invert
|
|
48
206
|
hash.each do |key, value|
|
|
49
|
-
|
|
50
|
-
|
|
207
|
+
# Apply field mapping (db_col => ruby_attr)
|
|
208
|
+
attr_name = mapping_reverse[key.to_s] || key
|
|
209
|
+
setter = "#{attr_name}="
|
|
210
|
+
instance.__send__(setter, value) if instance.respond_to?(setter)
|
|
51
211
|
end
|
|
52
212
|
instance.instance_variable_set(:@persisted, true)
|
|
53
213
|
instance
|
|
54
214
|
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
def find_by_id(id)
|
|
219
|
+
pk = primary_key_field || :id
|
|
220
|
+
sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
|
|
221
|
+
if soft_delete
|
|
222
|
+
sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
|
|
223
|
+
end
|
|
224
|
+
result = db.fetch_one(sql, [id])
|
|
225
|
+
return nil unless result
|
|
226
|
+
from_hash(result)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def find_by_filter(filter)
|
|
230
|
+
where_parts = filter.keys.map { |k| "#{k} = ?" }
|
|
231
|
+
sql = "SELECT * FROM #{table_name} WHERE #{where_parts.join(' AND ')}"
|
|
232
|
+
if soft_delete
|
|
233
|
+
sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
|
|
234
|
+
end
|
|
235
|
+
results = db.fetch(sql, filter.values)
|
|
236
|
+
results.map { |row| from_hash(row) }
|
|
237
|
+
end
|
|
55
238
|
end
|
|
56
239
|
|
|
57
240
|
def initialize(attributes = {})
|
|
58
241
|
@persisted = false
|
|
59
242
|
@errors = []
|
|
243
|
+
@relationship_cache = {}
|
|
60
244
|
attributes.each do |key, value|
|
|
61
245
|
setter = "#{key}="
|
|
62
|
-
|
|
246
|
+
__send__(setter, value) if respond_to?(setter)
|
|
63
247
|
end
|
|
64
248
|
# Set defaults
|
|
65
249
|
self.class.field_definitions.each do |name, opts|
|
|
66
|
-
if
|
|
67
|
-
|
|
250
|
+
if __send__(name).nil? && opts[:default]
|
|
251
|
+
__send__("#{name}=", opts[:default])
|
|
68
252
|
end
|
|
69
253
|
end
|
|
70
254
|
end
|
|
@@ -74,18 +258,21 @@ module Tina4
|
|
|
74
258
|
validate_fields
|
|
75
259
|
return false unless @errors.empty?
|
|
76
260
|
|
|
77
|
-
data =
|
|
261
|
+
data = to_db_hash(exclude_nil: true)
|
|
78
262
|
pk = self.class.primary_key_field || :id
|
|
79
|
-
pk_value =
|
|
263
|
+
pk_value = __send__(pk)
|
|
80
264
|
|
|
81
265
|
if @persisted && pk_value
|
|
82
266
|
filter = { pk => pk_value }
|
|
83
267
|
data.delete(pk)
|
|
268
|
+
# Remove mapped primary key too
|
|
269
|
+
mapped_pk = self.class.field_mapping[pk.to_s]
|
|
270
|
+
data.delete(mapped_pk.to_sym) if mapped_pk
|
|
84
271
|
self.class.db.update(self.class.table_name, data, filter)
|
|
85
272
|
else
|
|
86
273
|
result = self.class.db.insert(self.class.table_name, data)
|
|
87
274
|
if result[:last_id] && respond_to?("#{pk}=")
|
|
88
|
-
|
|
275
|
+
__send__("#{pk}=", result[:last_id])
|
|
89
276
|
end
|
|
90
277
|
@persisted = true
|
|
91
278
|
end
|
|
@@ -97,25 +284,75 @@ module Tina4
|
|
|
97
284
|
|
|
98
285
|
def delete
|
|
99
286
|
pk = self.class.primary_key_field || :id
|
|
100
|
-
pk_value =
|
|
287
|
+
pk_value = __send__(pk)
|
|
101
288
|
return false unless pk_value
|
|
102
289
|
|
|
290
|
+
if self.class.soft_delete
|
|
291
|
+
# Soft delete: set the flag
|
|
292
|
+
self.class.db.update(
|
|
293
|
+
self.class.table_name,
|
|
294
|
+
{ self.class.soft_delete_field => 1 },
|
|
295
|
+
{ pk => pk_value }
|
|
296
|
+
)
|
|
297
|
+
else
|
|
298
|
+
self.class.db.delete(self.class.table_name, { pk => pk_value })
|
|
299
|
+
end
|
|
300
|
+
@persisted = false
|
|
301
|
+
true
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def force_delete
|
|
305
|
+
pk = self.class.primary_key_field || :id
|
|
306
|
+
pk_value = __send__(pk)
|
|
307
|
+
raise "Cannot delete: no primary key value" unless pk_value
|
|
308
|
+
|
|
103
309
|
self.class.db.delete(self.class.table_name, { pk => pk_value })
|
|
104
310
|
@persisted = false
|
|
105
311
|
true
|
|
106
312
|
end
|
|
107
313
|
|
|
314
|
+
def restore
|
|
315
|
+
raise "Model does not support soft delete" unless self.class.soft_delete
|
|
316
|
+
|
|
317
|
+
pk = self.class.primary_key_field || :id
|
|
318
|
+
pk_value = __send__(pk)
|
|
319
|
+
raise "Cannot restore: no primary key value" unless pk_value
|
|
320
|
+
|
|
321
|
+
self.class.db.update(
|
|
322
|
+
self.class.table_name,
|
|
323
|
+
{ self.class.soft_delete_field => 0 },
|
|
324
|
+
{ pk => pk_value }
|
|
325
|
+
)
|
|
326
|
+
__send__("#{self.class.soft_delete_field}=", 0) if respond_to?("#{self.class.soft_delete_field}=")
|
|
327
|
+
true
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def validate
|
|
331
|
+
errors = []
|
|
332
|
+
self.class.field_definitions.each do |name, opts|
|
|
333
|
+
value = __send__(name)
|
|
334
|
+
if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default]
|
|
335
|
+
errors << "#{name} cannot be null"
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
errors
|
|
339
|
+
end
|
|
340
|
+
|
|
108
341
|
def load(id = nil)
|
|
109
342
|
pk = self.class.primary_key_field || :id
|
|
110
|
-
id ||=
|
|
343
|
+
id ||= __send__(pk)
|
|
111
344
|
return false unless id
|
|
112
345
|
|
|
113
|
-
result = self.class.db.fetch_one(
|
|
346
|
+
result = self.class.db.fetch_one(
|
|
347
|
+
"SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
|
|
348
|
+
)
|
|
114
349
|
return false unless result
|
|
115
350
|
|
|
351
|
+
mapping_reverse = self.class.field_mapping.invert
|
|
116
352
|
result.each do |key, value|
|
|
117
|
-
|
|
118
|
-
|
|
353
|
+
attr_name = mapping_reverse[key.to_s] || key
|
|
354
|
+
setter = "#{attr_name}="
|
|
355
|
+
__send__(setter, value) if respond_to?(setter)
|
|
119
356
|
end
|
|
120
357
|
@persisted = true
|
|
121
358
|
true
|
|
@@ -129,40 +366,109 @@ module Tina4
|
|
|
129
366
|
@errors
|
|
130
367
|
end
|
|
131
368
|
|
|
132
|
-
|
|
369
|
+
# Convert to hash using Ruby attribute names
|
|
370
|
+
def to_h
|
|
133
371
|
hash = {}
|
|
134
372
|
self.class.field_definitions.each_key do |name|
|
|
135
|
-
|
|
136
|
-
next if exclude_nil && value.nil?
|
|
137
|
-
hash[name] = value
|
|
373
|
+
hash[name] = __send__(name)
|
|
138
374
|
end
|
|
139
375
|
hash
|
|
140
376
|
end
|
|
141
377
|
|
|
378
|
+
alias to_hash to_h
|
|
379
|
+
alias to_dict to_h
|
|
380
|
+
alias to_object to_h
|
|
381
|
+
|
|
382
|
+
def to_array
|
|
383
|
+
to_h.values
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
alias to_list to_array
|
|
387
|
+
|
|
142
388
|
def to_json(*_args)
|
|
143
|
-
JSON.generate(
|
|
389
|
+
JSON.generate(to_h)
|
|
144
390
|
end
|
|
145
391
|
|
|
146
392
|
def to_s
|
|
147
|
-
"#<#{self.class.name} #{
|
|
393
|
+
"#<#{self.class.name} #{to_h}>"
|
|
148
394
|
end
|
|
149
395
|
|
|
150
396
|
def select(*fields)
|
|
151
397
|
fields_str = fields.map(&:to_s).join(", ")
|
|
152
398
|
pk = self.class.primary_key_field || :id
|
|
153
|
-
pk_value =
|
|
399
|
+
pk_value = __send__(pk)
|
|
154
400
|
self.class.db.fetch_one("SELECT #{fields_str} FROM #{self.class.table_name} WHERE #{pk} = ?", [pk_value])
|
|
155
401
|
end
|
|
156
402
|
|
|
157
403
|
private
|
|
158
404
|
|
|
405
|
+
# Convert to hash using DB column names (with field_mapping applied)
|
|
406
|
+
def to_db_hash(exclude_nil: false)
|
|
407
|
+
hash = {}
|
|
408
|
+
mapping = self.class.field_mapping
|
|
409
|
+
self.class.field_definitions.each_key do |name|
|
|
410
|
+
value = __send__(name)
|
|
411
|
+
next if exclude_nil && value.nil?
|
|
412
|
+
db_col = mapping[name.to_s] || name
|
|
413
|
+
hash[db_col.to_sym] = value
|
|
414
|
+
end
|
|
415
|
+
hash
|
|
416
|
+
end
|
|
417
|
+
|
|
159
418
|
def validate_fields
|
|
160
419
|
self.class.field_definitions.each do |name, opts|
|
|
161
|
-
value =
|
|
420
|
+
value = __send__(name)
|
|
162
421
|
if !opts[:nullable] && value.nil? && !opts[:auto_increment] && !opts[:default]
|
|
163
422
|
@errors << "#{name} cannot be null"
|
|
164
423
|
end
|
|
165
424
|
end
|
|
166
425
|
end
|
|
426
|
+
|
|
427
|
+
def load_has_one(name)
|
|
428
|
+
return @relationship_cache[name] if @relationship_cache.key?(name)
|
|
429
|
+
rel = self.class.relationship_definitions[name]
|
|
430
|
+
return nil unless rel
|
|
431
|
+
|
|
432
|
+
klass = Object.const_get(rel[:class_name])
|
|
433
|
+
pk = self.class.primary_key_field || :id
|
|
434
|
+
fk = rel[:foreign_key] || "#{self.class.name.split('::').last.downcase}_id"
|
|
435
|
+
pk_value = __send__(pk)
|
|
436
|
+
return nil unless pk_value
|
|
437
|
+
|
|
438
|
+
result = klass.db.fetch_one(
|
|
439
|
+
"SELECT * FROM #{klass.table_name} WHERE #{fk} = ?", [pk_value]
|
|
440
|
+
)
|
|
441
|
+
@relationship_cache[name] = result ? klass.from_hash(result) : nil
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def load_has_many(name)
|
|
445
|
+
return @relationship_cache[name] if @relationship_cache.key?(name)
|
|
446
|
+
rel = self.class.relationship_definitions[name]
|
|
447
|
+
return [] unless rel
|
|
448
|
+
|
|
449
|
+
klass = Object.const_get(rel[:class_name])
|
|
450
|
+
pk = self.class.primary_key_field || :id
|
|
451
|
+
fk = rel[:foreign_key] || "#{self.class.name.split('::').last.downcase}_id"
|
|
452
|
+
pk_value = __send__(pk)
|
|
453
|
+
return [] unless pk_value
|
|
454
|
+
|
|
455
|
+
results = klass.db.fetch(
|
|
456
|
+
"SELECT * FROM #{klass.table_name} WHERE #{fk} = ?", [pk_value]
|
|
457
|
+
)
|
|
458
|
+
@relationship_cache[name] = results.map { |row| klass.from_hash(row) }
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def load_belongs_to(name)
|
|
462
|
+
return @relationship_cache[name] if @relationship_cache.key?(name)
|
|
463
|
+
rel = self.class.relationship_definitions[name]
|
|
464
|
+
return nil unless rel
|
|
465
|
+
|
|
466
|
+
klass = Object.const_get(rel[:class_name])
|
|
467
|
+
fk = rel[:foreign_key] || "#{name}_id"
|
|
468
|
+
fk_value = __send__(fk.to_sym) if respond_to?(fk.to_sym)
|
|
469
|
+
return nil unless fk_value
|
|
470
|
+
|
|
471
|
+
@relationship_cache[name] = klass.find(fk_value)
|
|
472
|
+
end
|
|
167
473
|
end
|
|
168
474
|
end
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
/* Tina4 CSS Framework v2.0.0 | MIT License | https://github.com/tina4stack/tina4-css */
|
|
2
1
|
*,
|
|
3
2
|
*::before,
|
|
4
3
|
*::after {
|
|
@@ -1515,6 +1514,64 @@ textarea.form-control {
|
|
|
1515
1514
|
background-color: #212529;
|
|
1516
1515
|
}
|
|
1517
1516
|
|
|
1517
|
+
.pagination {
|
|
1518
|
+
display: flex;
|
|
1519
|
+
flex-wrap: wrap;
|
|
1520
|
+
padding-left: 0;
|
|
1521
|
+
list-style: none;
|
|
1522
|
+
gap: 0.25rem;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
.page-item.disabled .page-link {
|
|
1526
|
+
color: #6c757d;
|
|
1527
|
+
pointer-events: none;
|
|
1528
|
+
background-color: #fff;
|
|
1529
|
+
border-color: rgba(0, 0, 0, 0.1);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
.page-item.active .page-link {
|
|
1533
|
+
color: #fff;
|
|
1534
|
+
background-color: #4a90d9;
|
|
1535
|
+
border-color: #4a90d9;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
.page-link {
|
|
1539
|
+
display: block;
|
|
1540
|
+
padding: 0.375rem 0.75rem;
|
|
1541
|
+
color: #4a90d9;
|
|
1542
|
+
background-color: #fff;
|
|
1543
|
+
border: 1px solid rgba(0, 0, 0, 0.15);
|
|
1544
|
+
border-radius: 0.25rem;
|
|
1545
|
+
text-decoration: none;
|
|
1546
|
+
line-height: 1.25;
|
|
1547
|
+
transition: color 0.15s, background-color 0.15s, border-color 0.15s;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
.page-link:hover {
|
|
1551
|
+
color: #2a76c6;
|
|
1552
|
+
background-color: rgba(74, 144, 217, 0.08);
|
|
1553
|
+
border-color: rgba(0, 0, 0, 0.2);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
.page-link:focus {
|
|
1557
|
+
outline: 0;
|
|
1558
|
+
box-shadow: 0 0 0 0.2rem rgba(74, 144, 217, 0.25);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
.crud-img-preview {
|
|
1562
|
+
max-height: 200px;
|
|
1563
|
+
max-width: 100%;
|
|
1564
|
+
border-radius: 0.25rem;
|
|
1565
|
+
object-fit: cover;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
.crud-img-preview-sm {
|
|
1569
|
+
max-height: 180px;
|
|
1570
|
+
max-width: 100%;
|
|
1571
|
+
border-radius: 0.25rem;
|
|
1572
|
+
object-fit: cover;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1518
1575
|
.d-none {
|
|
1519
1576
|
display: none !important;
|
|
1520
1577
|
}
|
|
@@ -1793,6 +1850,66 @@ textarea.form-control {
|
|
|
1793
1850
|
margin-right: 3rem !important;
|
|
1794
1851
|
}
|
|
1795
1852
|
|
|
1853
|
+
.my-0 {
|
|
1854
|
+
margin-top: 0 !important;
|
|
1855
|
+
margin-bottom: 0 !important;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
.mx-0 {
|
|
1859
|
+
margin-left: 0 !important;
|
|
1860
|
+
margin-right: 0 !important;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
.my-1 {
|
|
1864
|
+
margin-top: 0.25rem !important;
|
|
1865
|
+
margin-bottom: 0.25rem !important;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
.mx-1 {
|
|
1869
|
+
margin-left: 0.25rem !important;
|
|
1870
|
+
margin-right: 0.25rem !important;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
.my-2 {
|
|
1874
|
+
margin-top: 0.5rem !important;
|
|
1875
|
+
margin-bottom: 0.5rem !important;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
.mx-2 {
|
|
1879
|
+
margin-left: 0.5rem !important;
|
|
1880
|
+
margin-right: 0.5rem !important;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
.my-3 {
|
|
1884
|
+
margin-top: 1rem !important;
|
|
1885
|
+
margin-bottom: 1rem !important;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
.mx-3 {
|
|
1889
|
+
margin-left: 1rem !important;
|
|
1890
|
+
margin-right: 1rem !important;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
.my-4 {
|
|
1894
|
+
margin-top: 1.5rem !important;
|
|
1895
|
+
margin-bottom: 1.5rem !important;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
.mx-4 {
|
|
1899
|
+
margin-left: 1.5rem !important;
|
|
1900
|
+
margin-right: 1.5rem !important;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
.my-5 {
|
|
1904
|
+
margin-top: 3rem !important;
|
|
1905
|
+
margin-bottom: 3rem !important;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
.mx-5 {
|
|
1909
|
+
margin-left: 3rem !important;
|
|
1910
|
+
margin-right: 3rem !important;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1796
1913
|
.p-0 {
|
|
1797
1914
|
padding: 0 !important;
|
|
1798
1915
|
}
|
|
@@ -1913,6 +2030,66 @@ textarea.form-control {
|
|
|
1913
2030
|
padding-right: 3rem !important;
|
|
1914
2031
|
}
|
|
1915
2032
|
|
|
2033
|
+
.py-0 {
|
|
2034
|
+
padding-top: 0 !important;
|
|
2035
|
+
padding-bottom: 0 !important;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
.px-0 {
|
|
2039
|
+
padding-left: 0 !important;
|
|
2040
|
+
padding-right: 0 !important;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
.py-1 {
|
|
2044
|
+
padding-top: 0.25rem !important;
|
|
2045
|
+
padding-bottom: 0.25rem !important;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
.px-1 {
|
|
2049
|
+
padding-left: 0.25rem !important;
|
|
2050
|
+
padding-right: 0.25rem !important;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
.py-2 {
|
|
2054
|
+
padding-top: 0.5rem !important;
|
|
2055
|
+
padding-bottom: 0.5rem !important;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
.px-2 {
|
|
2059
|
+
padding-left: 0.5rem !important;
|
|
2060
|
+
padding-right: 0.5rem !important;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
.py-3 {
|
|
2064
|
+
padding-top: 1rem !important;
|
|
2065
|
+
padding-bottom: 1rem !important;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
.px-3 {
|
|
2069
|
+
padding-left: 1rem !important;
|
|
2070
|
+
padding-right: 1rem !important;
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
.py-4 {
|
|
2074
|
+
padding-top: 1.5rem !important;
|
|
2075
|
+
padding-bottom: 1.5rem !important;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
.px-4 {
|
|
2079
|
+
padding-left: 1.5rem !important;
|
|
2080
|
+
padding-right: 1.5rem !important;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
.py-5 {
|
|
2084
|
+
padding-top: 3rem !important;
|
|
2085
|
+
padding-bottom: 3rem !important;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
.px-5 {
|
|
2089
|
+
padding-left: 3rem !important;
|
|
2090
|
+
padding-right: 3rem !important;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
1916
2093
|
.mx-auto {
|
|
1917
2094
|
margin-left: auto !important;
|
|
1918
2095
|
margin-right: auto !important;
|