tina4ruby 3.11.32 → 3.11.36
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/lib/tina4/drivers/firebird_driver.rb +71 -13
- data/lib/tina4/orm.rb +98 -207
- data/lib/tina4/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9615f251d5bed0d97024c8e061bdf34d26afb2677e5c6ec989005d45d3edc6c9
|
|
4
|
+
data.tar.gz: 7ef7b684a87906e932f3c929cfe57596f67bf81bf63cf35e79b8d343f2ab297a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ecf726e25d06e8ecbed12874fa4f36edf20286ea10c2c9b6c973a2d9d430dd38933a95240b8d50dd4237b82e076b634527e0c28016aa6e94f6f71a7747d126f8
|
|
7
|
+
data.tar.gz: bc0603a6370d2dc27f26ad5cee86c00f233213d57dc904075f70c5befdf28ff81b78d0c25ffa25a42f34ae9cf48d0a33a322e1147d12bc05db1f125db245538f
|
|
@@ -5,6 +5,20 @@ module Tina4
|
|
|
5
5
|
class FirebirdDriver
|
|
6
6
|
attr_reader :connection
|
|
7
7
|
|
|
8
|
+
# Substring markers (lowercased) that identify a dead-socket Firebird
|
|
9
|
+
# error worth reconnecting for. Idle Firebird connections die silently
|
|
10
|
+
# behind NAT timeouts, server-side ConnectionIdleTimeout, or Docker
|
|
11
|
+
# network rotation; without this the next prepare crashes the request.
|
|
12
|
+
DEAD_CONN_MARKERS = [
|
|
13
|
+
"error writing data to the connection",
|
|
14
|
+
"error reading data from the connection",
|
|
15
|
+
"connection shutdown",
|
|
16
|
+
"connection lost",
|
|
17
|
+
"network error",
|
|
18
|
+
"connection is not active",
|
|
19
|
+
"broken pipe"
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
8
22
|
def connect(connection_string, username: nil, password: nil)
|
|
9
23
|
require "fb"
|
|
10
24
|
require "uri"
|
|
@@ -21,10 +35,13 @@ module Tina4
|
|
|
21
35
|
db_path || connection_string.sub(/^firebird:\/\//, "")
|
|
22
36
|
end
|
|
23
37
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@
|
|
38
|
+
# Cache for transparent reconnect — never logged, lives only in
|
|
39
|
+
# driver memory alongside the connection it owns.
|
|
40
|
+
@connect_opts = { database: database }
|
|
41
|
+
@connect_opts[:username] = db_user if db_user
|
|
42
|
+
@connect_opts[:password] = db_pass if db_pass
|
|
43
|
+
|
|
44
|
+
open_connection
|
|
28
45
|
rescue LoadError
|
|
29
46
|
raise "Firebird driver requires the 'fb' gem. Install it with: gem install fb"
|
|
30
47
|
end
|
|
@@ -34,22 +51,35 @@ module Tina4
|
|
|
34
51
|
end
|
|
35
52
|
|
|
36
53
|
def execute_query(sql, params = [])
|
|
37
|
-
rows =
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
rows = with_reconnect do
|
|
55
|
+
if params.empty?
|
|
56
|
+
@connection.query(:hash, sql)
|
|
57
|
+
else
|
|
58
|
+
@connection.query(:hash, sql, *params)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
42
61
|
rows.map { |row| decode_blobs(stringify_keys(row)) }
|
|
43
62
|
end
|
|
44
63
|
|
|
45
64
|
def execute(sql, params = [])
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
with_reconnect do
|
|
66
|
+
if params.empty?
|
|
67
|
+
@connection.execute(sql)
|
|
68
|
+
else
|
|
69
|
+
@connection.execute(sql, *params)
|
|
70
|
+
end
|
|
50
71
|
end
|
|
51
72
|
end
|
|
52
73
|
|
|
74
|
+
# Public so specs (and curious operators) can verify the matcher
|
|
75
|
+
# behaviour without poking private methods.
|
|
76
|
+
def self.dead_connection?(error_or_message)
|
|
77
|
+
msg = error_or_message.respond_to?(:message) ? error_or_message.message : error_or_message.to_s
|
|
78
|
+
return false if msg.nil? || msg.empty?
|
|
79
|
+
lower = msg.downcase
|
|
80
|
+
DEAD_CONN_MARKERS.any? { |m| lower.include?(m) }
|
|
81
|
+
end
|
|
82
|
+
|
|
53
83
|
def last_insert_id
|
|
54
84
|
nil
|
|
55
85
|
end
|
|
@@ -103,6 +133,34 @@ module Tina4
|
|
|
103
133
|
|
|
104
134
|
private
|
|
105
135
|
|
|
136
|
+
def open_connection
|
|
137
|
+
@connection = Fb::Database.new(**@connect_opts).connect
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Force-close a stale handle and reopen using cached opts. Idempotent —
|
|
141
|
+
# safe to call when the connection is already gone.
|
|
142
|
+
def reconnect!
|
|
143
|
+
begin
|
|
144
|
+
@connection&.close
|
|
145
|
+
rescue StandardError
|
|
146
|
+
# connection already gone — nothing to clean up
|
|
147
|
+
end
|
|
148
|
+
@connection = nil
|
|
149
|
+
@transaction = nil
|
|
150
|
+
open_connection
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Run a block; if it raises with a dead-connection signature, reconnect
|
|
154
|
+
# once and retry. Skipped inside an explicit transaction — atomicity
|
|
155
|
+
# beats resilience there; the caller handles rollback.
|
|
156
|
+
def with_reconnect
|
|
157
|
+
yield
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
raise unless self.class.dead_connection?(e) && @transaction.nil?
|
|
160
|
+
reconnect!
|
|
161
|
+
yield
|
|
162
|
+
end
|
|
163
|
+
|
|
106
164
|
def stringify_keys(hash)
|
|
107
165
|
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
108
166
|
end
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -4,7 +4,7 @@ require "json"
|
|
|
4
4
|
module Tina4
|
|
5
5
|
# Convert a snake_case name to camelCase.
|
|
6
6
|
def self.snake_to_camel(name)
|
|
7
|
-
parts = name.to_s.
|
|
7
|
+
parts = name.to_s.split("_")
|
|
8
8
|
parts[0] + parts[1..].map(&:capitalize).join
|
|
9
9
|
end
|
|
10
10
|
|
|
@@ -18,7 +18,7 @@ module Tina4
|
|
|
18
18
|
|
|
19
19
|
class << self
|
|
20
20
|
def db
|
|
21
|
-
@db || Tina4.database
|
|
21
|
+
@db || Tina4.database
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
# Per-model database binding
|
|
@@ -52,24 +52,28 @@ module Tina4
|
|
|
52
52
|
@field_mapping = map
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
# Auto-map flag
|
|
55
|
+
# Auto-map flag — defaults to TRUE for cross-framework parity (Python's
|
|
56
|
+
# ORM has auto_map=True by default). The instance variable is treated
|
|
57
|
+
# as "unset" when nil; only an explicit `false` disables it.
|
|
56
58
|
def auto_map
|
|
57
|
-
@auto_map.nil? ?
|
|
59
|
+
defined?(@auto_map) && !@auto_map.nil? ? @auto_map : true
|
|
58
60
|
end
|
|
59
61
|
|
|
60
62
|
def auto_map=(val)
|
|
61
63
|
@auto_map = val
|
|
62
64
|
end
|
|
63
65
|
|
|
64
|
-
#
|
|
66
|
+
# auto_crud flag — when set to true, the class registers itself with
|
|
67
|
+
# Tina4::AutoCrud which auto-generates REST endpoints from the model.
|
|
68
|
+
# Defaults to false. Cross-framework parity with Python's autoCrud.
|
|
65
69
|
def auto_crud
|
|
66
|
-
@auto_crud
|
|
70
|
+
defined?(@auto_crud) && !@auto_crud.nil? ? @auto_crud : false
|
|
67
71
|
end
|
|
68
72
|
|
|
69
73
|
def auto_crud=(val)
|
|
70
74
|
@auto_crud = val
|
|
71
|
-
if val
|
|
72
|
-
Tina4::AutoCrud.
|
|
75
|
+
if val && defined?(::Tina4::AutoCrud)
|
|
76
|
+
::Tina4::AutoCrud.models << self unless ::Tina4::AutoCrud.models.include?(self)
|
|
73
77
|
end
|
|
74
78
|
end
|
|
75
79
|
|
|
@@ -79,7 +83,7 @@ module Tina4
|
|
|
79
83
|
end
|
|
80
84
|
|
|
81
85
|
# has_one :profile, class_name: "Profile", foreign_key: "user_id"
|
|
82
|
-
def has_one(name, class_name: nil, foreign_key: nil)
|
|
86
|
+
def has_one(name, class_name: nil, foreign_key: nil)
|
|
83
87
|
relationship_definitions[name] = {
|
|
84
88
|
type: :has_one,
|
|
85
89
|
class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
|
|
@@ -92,7 +96,7 @@ module Tina4
|
|
|
92
96
|
end
|
|
93
97
|
|
|
94
98
|
# has_many :posts, class_name: "Post", foreign_key: "user_id"
|
|
95
|
-
def has_many(name, class_name: nil, foreign_key: nil)
|
|
99
|
+
def has_many(name, class_name: nil, foreign_key: nil)
|
|
96
100
|
relationship_definitions[name] = {
|
|
97
101
|
type: :has_many,
|
|
98
102
|
class_name: class_name || name.to_s.sub(/s$/, "").split("_").map(&:capitalize).join,
|
|
@@ -105,7 +109,7 @@ module Tina4
|
|
|
105
109
|
end
|
|
106
110
|
|
|
107
111
|
# belongs_to :user, class_name: "User", foreign_key: "user_id"
|
|
108
|
-
def belongs_to(name, class_name: nil, foreign_key: nil)
|
|
112
|
+
def belongs_to(name, class_name: nil, foreign_key: nil)
|
|
109
113
|
relationship_definitions[name] = {
|
|
110
114
|
type: :belongs_to,
|
|
111
115
|
class_name: class_name || name.to_s.split("_").map(&:capitalize).join,
|
|
@@ -123,46 +127,31 @@ module Tina4
|
|
|
123
127
|
# results = User.query.where("active = ?", [1]).order_by("name").get
|
|
124
128
|
#
|
|
125
129
|
# @return [Tina4::QueryBuilder]
|
|
126
|
-
def query
|
|
127
|
-
QueryBuilder.
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
130
|
+
def query
|
|
131
|
+
QueryBuilder.from(table_name, db: db)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def find(id_or_filter = nil, filter = nil, **kwargs)
|
|
135
|
+
include_list = kwargs.delete(:include)
|
|
136
|
+
|
|
137
|
+
# find(id) — find by primary key
|
|
138
|
+
# find(filter_hash) — find by criteria
|
|
139
|
+
# find(name: "Alice") — keyword args as filter hash
|
|
140
|
+
result = if id_or_filter.is_a?(Hash)
|
|
141
|
+
find_by_filter(id_or_filter)
|
|
142
|
+
elsif filter.is_a?(Hash)
|
|
143
|
+
find_by_filter(filter)
|
|
144
|
+
elsif !kwargs.empty?
|
|
145
|
+
find_by_filter(kwargs)
|
|
146
|
+
else
|
|
147
|
+
find_by_id(id_or_filter)
|
|
152
148
|
end
|
|
153
149
|
|
|
154
|
-
if
|
|
155
|
-
|
|
150
|
+
if include_list && result
|
|
151
|
+
instances = result.is_a?(Array) ? result : [result]
|
|
152
|
+
eager_load(instances, include_list)
|
|
156
153
|
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
|
|
154
|
+
result
|
|
166
155
|
end
|
|
167
156
|
|
|
168
157
|
# Eager load relationships for a collection of instances (prevents N+1).
|
|
@@ -243,20 +232,20 @@ module Tina4
|
|
|
243
232
|
end
|
|
244
233
|
end
|
|
245
234
|
|
|
246
|
-
def where(conditions, params = [],
|
|
235
|
+
def where(conditions, params = [], include: nil)
|
|
247
236
|
sql = "SELECT * FROM #{table_name}"
|
|
248
237
|
if soft_delete
|
|
249
238
|
sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})"
|
|
250
239
|
else
|
|
251
240
|
sql += " WHERE #{conditions}"
|
|
252
241
|
end
|
|
253
|
-
results = db.fetch(sql, params
|
|
242
|
+
results = db.fetch(sql, params)
|
|
254
243
|
instances = results.map { |row| from_hash(row) }
|
|
255
244
|
eager_load(instances, include) if include
|
|
256
245
|
instances
|
|
257
246
|
end
|
|
258
247
|
|
|
259
|
-
def all(limit: nil, offset: nil, order_by: nil, include: nil)
|
|
248
|
+
def all(limit: nil, offset: nil, order_by: nil, include: nil)
|
|
260
249
|
sql = "SELECT * FROM #{table_name}"
|
|
261
250
|
if soft_delete
|
|
262
251
|
sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0"
|
|
@@ -268,19 +257,19 @@ module Tina4
|
|
|
268
257
|
instances
|
|
269
258
|
end
|
|
270
259
|
|
|
271
|
-
def select(sql, params = [], limit: nil, offset: nil, include: nil)
|
|
260
|
+
def select(sql, params = [], limit: nil, offset: nil, include: nil)
|
|
272
261
|
results = db.fetch(sql, params, limit: limit, offset: offset)
|
|
273
262
|
instances = results.map { |row| from_hash(row) }
|
|
274
263
|
eager_load(instances, include) if include
|
|
275
264
|
instances
|
|
276
265
|
end
|
|
277
266
|
|
|
278
|
-
def select_one(sql, params = [], include: nil)
|
|
267
|
+
def select_one(sql, params = [], include: nil)
|
|
279
268
|
results = select(sql, params, limit: 1, include: include)
|
|
280
269
|
results.first
|
|
281
270
|
end
|
|
282
271
|
|
|
283
|
-
def count(conditions = nil, params = [])
|
|
272
|
+
def count(conditions = nil, params = [])
|
|
284
273
|
sql = "SELECT COUNT(*) as cnt FROM #{table_name}"
|
|
285
274
|
where_parts = []
|
|
286
275
|
if soft_delete
|
|
@@ -292,48 +281,25 @@ module Tina4
|
|
|
292
281
|
result[:cnt].to_i
|
|
293
282
|
end
|
|
294
283
|
|
|
295
|
-
def create(attributes = {})
|
|
284
|
+
def create(attributes = {})
|
|
296
285
|
instance = new(attributes)
|
|
297
286
|
instance.save
|
|
298
287
|
instance
|
|
299
288
|
end
|
|
300
289
|
|
|
301
|
-
def find_or_fail(id)
|
|
290
|
+
def find_or_fail(id)
|
|
302
291
|
result = find(id)
|
|
303
292
|
raise "#{name} with #{primary_key_field || :id}=#{id} not found" if result.nil?
|
|
304
293
|
result
|
|
305
294
|
end
|
|
306
295
|
|
|
307
|
-
|
|
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]
|
|
296
|
+
def with_trashed(conditions = "1=1", params = [], limit: 20, offset: 0)
|
|
331
297
|
sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
|
|
332
298
|
results = db.fetch(sql, params, limit: limit, offset: offset)
|
|
333
299
|
results.map { |row| from_hash(row) }
|
|
334
300
|
end
|
|
335
301
|
|
|
336
|
-
def create_table
|
|
302
|
+
def create_table
|
|
337
303
|
return true if db.table_exists?(table_name)
|
|
338
304
|
|
|
339
305
|
type_map = {
|
|
@@ -374,7 +340,7 @@ module Tina4
|
|
|
374
340
|
true
|
|
375
341
|
end
|
|
376
342
|
|
|
377
|
-
def scope(name, filter_sql, params = [])
|
|
343
|
+
def scope(name, filter_sql, params = [])
|
|
378
344
|
define_singleton_method(name) do |limit: 20, offset: 0|
|
|
379
345
|
where(filter_sql, params)
|
|
380
346
|
end
|
|
@@ -393,42 +359,17 @@ module Tina4
|
|
|
393
359
|
instance
|
|
394
360
|
end
|
|
395
361
|
|
|
396
|
-
#
|
|
397
|
-
|
|
362
|
+
# find_by_id is PUBLIC — cross-framework parity with Python's
|
|
363
|
+
# MyModel.find_by_id(pk_value) and PHP's User::find($id). Spec at
|
|
364
|
+
# spec/orm_spec.rb:78 verifies public access. find_by_filter stays
|
|
365
|
+
# public for the same reason; both are part of the documented API.
|
|
366
|
+
def find_by_id(id)
|
|
398
367
|
pk = primary_key_field || :id
|
|
399
368
|
sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
|
|
400
369
|
if soft_delete
|
|
401
370
|
sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
|
|
402
371
|
end
|
|
403
|
-
select_one(sql, [id]
|
|
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
|
|
372
|
+
select_one(sql, [id])
|
|
432
373
|
end
|
|
433
374
|
|
|
434
375
|
def find_by_filter(filter)
|
|
@@ -458,7 +399,7 @@ module Tina4
|
|
|
458
399
|
end
|
|
459
400
|
end
|
|
460
401
|
|
|
461
|
-
def save
|
|
402
|
+
def save
|
|
462
403
|
@errors = []
|
|
463
404
|
@relationship_cache = {} # Clear relationship cache on save
|
|
464
405
|
validate_fields
|
|
@@ -490,7 +431,7 @@ module Tina4
|
|
|
490
431
|
false
|
|
491
432
|
end
|
|
492
433
|
|
|
493
|
-
def delete
|
|
434
|
+
def delete
|
|
494
435
|
pk = self.class.primary_key_field || :id
|
|
495
436
|
pk_value = __send__(pk)
|
|
496
437
|
return false unless pk_value
|
|
@@ -510,7 +451,7 @@ module Tina4
|
|
|
510
451
|
true
|
|
511
452
|
end
|
|
512
453
|
|
|
513
|
-
def force_delete
|
|
454
|
+
def force_delete
|
|
514
455
|
pk = self.class.primary_key_field || :id
|
|
515
456
|
pk_value = __send__(pk)
|
|
516
457
|
raise "Cannot delete: no primary key value" unless pk_value
|
|
@@ -522,7 +463,7 @@ module Tina4
|
|
|
522
463
|
true
|
|
523
464
|
end
|
|
524
465
|
|
|
525
|
-
def restore
|
|
466
|
+
def restore
|
|
526
467
|
raise "Model does not support soft delete" unless self.class.soft_delete
|
|
527
468
|
|
|
528
469
|
pk = self.class.primary_key_field || :id
|
|
@@ -540,7 +481,7 @@ module Tina4
|
|
|
540
481
|
true
|
|
541
482
|
end
|
|
542
483
|
|
|
543
|
-
def validate
|
|
484
|
+
def validate
|
|
544
485
|
errors = []
|
|
545
486
|
self.class.field_definitions.each do |name, opts|
|
|
546
487
|
value = __send__(name)
|
|
@@ -551,36 +492,34 @@ module Tina4
|
|
|
551
492
|
errors
|
|
552
493
|
end
|
|
553
494
|
|
|
554
|
-
#
|
|
555
|
-
# Returns true if found and loaded, false otherwise.
|
|
556
|
-
# Load a record into this instance.
|
|
495
|
+
# load — populate this instance from the database.
|
|
557
496
|
#
|
|
558
|
-
#
|
|
559
|
-
#
|
|
560
|
-
#
|
|
561
|
-
#
|
|
497
|
+
# Three forms (parity with Python's model.load(sql, params, include)):
|
|
498
|
+
# user.load # reload by primary key from instance
|
|
499
|
+
# user.load(123) # load by primary key value
|
|
500
|
+
# user.load("email = ?", ["a@b.c"]) # load by filter SQL + params (selectOne)
|
|
562
501
|
#
|
|
563
|
-
# Returns true
|
|
564
|
-
def load(
|
|
565
|
-
@relationship_cache = {}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
if
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
return false if pk_value.nil?
|
|
573
|
-
sql = "SELECT * FROM #{table} WHERE #{pk_col} = ?"
|
|
574
|
-
params = [pk_value]
|
|
502
|
+
# Returns true on hit, false on miss. Always clears the relationship cache.
|
|
503
|
+
def load(arg = nil, params = nil)
|
|
504
|
+
@relationship_cache = {} # Clear relationship cache on reload
|
|
505
|
+
pk = self.class.primary_key_field || :id
|
|
506
|
+
|
|
507
|
+
if arg.is_a?(String)
|
|
508
|
+
# Filter-SQL form: user.load("email = ?", ["a@b.c"])
|
|
509
|
+
sql = "SELECT * FROM #{self.class.table_name} WHERE #{arg} LIMIT 1"
|
|
510
|
+
result = self.class.db.fetch_one(sql, params || [])
|
|
575
511
|
else
|
|
576
|
-
|
|
512
|
+
# Primary-key form: user.load OR user.load(123)
|
|
513
|
+
id = arg || __send__(pk)
|
|
514
|
+
return false unless id
|
|
515
|
+
result = self.class.db.fetch_one(
|
|
516
|
+
"SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
|
|
517
|
+
)
|
|
577
518
|
end
|
|
578
|
-
|
|
579
|
-
result = self.class.select_one(sql, params, include: include)
|
|
580
519
|
return false unless result
|
|
581
520
|
|
|
582
521
|
mapping_reverse = self.class.field_mapping.invert
|
|
583
|
-
result.
|
|
522
|
+
result.each do |key, value|
|
|
584
523
|
attr_name = mapping_reverse[key.to_s] || key
|
|
585
524
|
setter = "#{attr_name}="
|
|
586
525
|
__send__(setter, value) if respond_to?(setter)
|
|
@@ -599,8 +538,10 @@ module Tina4
|
|
|
599
538
|
|
|
600
539
|
# Convert to hash using Ruby attribute names.
|
|
601
540
|
# Optionally include relationships via the include keyword.
|
|
602
|
-
# case:
|
|
603
|
-
|
|
541
|
+
# case: "camel" converts snake_case keys to camelCase (parity with
|
|
542
|
+
# Python's to_dict(case='camel')). Default keeps native snake_case.
|
|
543
|
+
def to_h(include: nil, case: nil)
|
|
544
|
+
key_case = binding.local_variable_get(:case) # :case is a reserved word
|
|
604
545
|
hash = {}
|
|
605
546
|
self.class.field_definitions.each_key do |name|
|
|
606
547
|
hash[name] = __send__(name)
|
|
@@ -622,49 +563,36 @@ module Tina4
|
|
|
622
563
|
if related.nil?
|
|
623
564
|
hash[rel_name] = nil
|
|
624
565
|
elsif related.is_a?(Array)
|
|
625
|
-
hash[rel_name] = related.map { |r| r.to_h(include: nested.empty? ? nil : nested
|
|
566
|
+
hash[rel_name] = related.map { |r| r.to_h(include: nested.empty? ? nil : nested) }
|
|
626
567
|
else
|
|
627
|
-
hash[rel_name] = related.to_h(include: nested.empty? ? nil : nested
|
|
568
|
+
hash[rel_name] = related.to_h(include: nested.empty? ? nil : nested)
|
|
628
569
|
end
|
|
629
570
|
end
|
|
630
571
|
end
|
|
631
572
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
573
|
+
if key_case == "camel" || key_case == :camel
|
|
574
|
+
# snake_case → camelCase: split on _, capitalize all but the first
|
|
575
|
+
hash = hash.each_with_object({}) do |(k, v), out|
|
|
576
|
+
parts = k.to_s.split("_")
|
|
577
|
+
camel = parts[0] + parts[1..].map(&:capitalize).join
|
|
578
|
+
out[camel.to_sym] = v
|
|
638
579
|
end
|
|
639
|
-
return camel_hash
|
|
640
580
|
end
|
|
641
581
|
|
|
642
582
|
hash
|
|
643
583
|
end
|
|
644
584
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
def to_dict(include: nil, case: 'snake')
|
|
650
|
-
to_h(include: include, case: binding.local_variable_get(:case))
|
|
651
|
-
end
|
|
585
|
+
alias to_hash to_h
|
|
586
|
+
alias to_dict to_h
|
|
587
|
+
alias to_object to_h
|
|
652
588
|
|
|
653
|
-
def
|
|
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
|
|
589
|
+
def to_array
|
|
662
590
|
to_h.values
|
|
663
591
|
end
|
|
664
592
|
|
|
665
593
|
alias to_list to_array
|
|
666
594
|
|
|
667
|
-
def to_json(include: nil, **_args)
|
|
595
|
+
def to_json(include: nil, **_args)
|
|
668
596
|
JSON.generate(to_h(include: include))
|
|
669
597
|
end
|
|
670
598
|
|
|
@@ -747,44 +675,7 @@ module Tina4
|
|
|
747
675
|
fk_value = __send__(fk.to_sym) if respond_to?(fk.to_sym)
|
|
748
676
|
return nil unless fk_value
|
|
749
677
|
|
|
750
|
-
@relationship_cache[name] = klass.
|
|
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)
|
|
678
|
+
@relationship_cache[name] = klass.find(fk_value)
|
|
788
679
|
end
|
|
789
680
|
end
|
|
790
681
|
end
|
data/lib/tina4/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.11.
|
|
4
|
+
version: 3.11.36
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04
|
|
11
|
+
date: 2026-05-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|