tina4ruby 3.13.23 → 3.13.26
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/cache_backends/base_backend.rb +58 -0
- data/lib/tina4/cache_backends/database_backend.rb +121 -0
- data/lib/tina4/cache_backends/file_backend.rb +140 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +103 -0
- data/lib/tina4/cache_backends/memory_backend.rb +81 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +133 -0
- data/lib/tina4/cache_backends/redis_backend.rb +233 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +16 -0
- data/lib/tina4/cache_backends.rb +91 -0
- data/lib/tina4/database.rb +95 -0
- data/lib/tina4/response_cache.rb +71 -243
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +25 -2
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "base_backend"
|
|
7
|
+
|
|
8
|
+
module Tina4
|
|
9
|
+
module CacheBackends
|
|
10
|
+
# Redis / Valkey backend (parity with Python _RedisBackend). Uses the
|
|
11
|
+
# `redis` gem if it is installed, otherwise falls back to the raw RESP
|
|
12
|
+
# protocol over a TCP socket — so it works with zero runtime dependencies.
|
|
13
|
+
#
|
|
14
|
+
# URL form: scheme://[user[:password]@]host[:port][/db]. Credentials may
|
|
15
|
+
# also be supplied via TINA4_CACHE_USERNAME / TINA4_CACHE_PASSWORD (parity
|
|
16
|
+
# with TINA4_DATABASE_USERNAME / TINA4_DATABASE_PASSWORD). Wrong credentials
|
|
17
|
+
# cause available? to return false, so the factory falls back to file.
|
|
18
|
+
class RedisBackend < BaseBackend
|
|
19
|
+
PREFIX = "tina4:cache:"
|
|
20
|
+
|
|
21
|
+
def initialize(url: "redis://localhost:6379", max_entries: 1000, name: "redis")
|
|
22
|
+
@max_entries = max_entries
|
|
23
|
+
@name = name
|
|
24
|
+
@hits = 0
|
|
25
|
+
@misses = 0
|
|
26
|
+
@client = nil
|
|
27
|
+
@use_raw = false
|
|
28
|
+
|
|
29
|
+
parse_url(url)
|
|
30
|
+
|
|
31
|
+
# Try the redis gem first.
|
|
32
|
+
begin
|
|
33
|
+
require "redis"
|
|
34
|
+
kwargs = { host: @host, port: @port, db: @db, timeout: 5 }
|
|
35
|
+
kwargs[:password] = @password if @password
|
|
36
|
+
kwargs[:username] = @username if @username
|
|
37
|
+
@client = Redis.new(**kwargs)
|
|
38
|
+
@client.ping
|
|
39
|
+
@available = true
|
|
40
|
+
rescue LoadError, StandardError
|
|
41
|
+
@client = nil
|
|
42
|
+
@use_raw = true
|
|
43
|
+
# No gem — usable only if the server answers (and authenticates).
|
|
44
|
+
@available = probe
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def available?
|
|
49
|
+
@available
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def get(key)
|
|
53
|
+
full_key = PREFIX + key
|
|
54
|
+
raw = if @client
|
|
55
|
+
begin
|
|
56
|
+
@client.get(full_key)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
elsif @use_raw
|
|
61
|
+
resp_command("GET", full_key)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if raw.nil?
|
|
65
|
+
@misses += 1
|
|
66
|
+
return nil
|
|
67
|
+
end
|
|
68
|
+
@hits += 1
|
|
69
|
+
begin
|
|
70
|
+
JSON.parse(raw)
|
|
71
|
+
rescue JSON::ParserError, TypeError
|
|
72
|
+
raw
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def set(key, value, ttl)
|
|
77
|
+
full_key = PREFIX + key
|
|
78
|
+
serialized = JSON.generate(value)
|
|
79
|
+
if @client
|
|
80
|
+
begin
|
|
81
|
+
if ttl > 0
|
|
82
|
+
@client.setex(full_key, ttl, serialized)
|
|
83
|
+
else
|
|
84
|
+
@client.set(full_key, serialized)
|
|
85
|
+
end
|
|
86
|
+
rescue StandardError
|
|
87
|
+
end
|
|
88
|
+
elsif @use_raw
|
|
89
|
+
if ttl > 0
|
|
90
|
+
resp_command("SETEX", full_key, ttl.to_s, serialized)
|
|
91
|
+
else
|
|
92
|
+
resp_command("SET", full_key, serialized)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def delete(key)
|
|
98
|
+
full_key = PREFIX + key
|
|
99
|
+
if @client
|
|
100
|
+
begin
|
|
101
|
+
@client.del(full_key) > 0
|
|
102
|
+
rescue StandardError
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
elsif @use_raw
|
|
106
|
+
resp_command("DEL", full_key) == "1"
|
|
107
|
+
else
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def clear
|
|
113
|
+
@hits = 0
|
|
114
|
+
@misses = 0
|
|
115
|
+
if @client
|
|
116
|
+
begin
|
|
117
|
+
keys = @client.keys("#{PREFIX}*")
|
|
118
|
+
@client.del(*keys) unless keys.empty?
|
|
119
|
+
rescue StandardError
|
|
120
|
+
end
|
|
121
|
+
elsif @use_raw
|
|
122
|
+
# Raw RESP doesn't support pattern delete easily; rely on TTL.
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def stats
|
|
127
|
+
size = 0
|
|
128
|
+
if @client
|
|
129
|
+
begin
|
|
130
|
+
size = @client.keys("#{PREFIX}*").size
|
|
131
|
+
rescue StandardError
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
{ hits: @hits, misses: @misses, size: size, backend: @name }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def name
|
|
138
|
+
@name
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def parse_url(url)
|
|
144
|
+
url = "redis://#{url}" unless url.include?("://")
|
|
145
|
+
# Normalise valkey:// → redis:// so URI parses host/port/userinfo.
|
|
146
|
+
normalised = url.sub(%r{^valkey://}, "redis://")
|
|
147
|
+
uri = URI.parse(normalised)
|
|
148
|
+
@host = uri.host || "localhost"
|
|
149
|
+
@port = uri.port || 6379
|
|
150
|
+
db_path = (uri.path || "").sub(%r{^/}, "")
|
|
151
|
+
@db = db_path =~ /\A\d+\z/ ? db_path.to_i : 0
|
|
152
|
+
url_user = uri.user && !uri.user.empty? ? URI.decode_www_form_component(uri.user) : nil
|
|
153
|
+
url_pass = uri.password && !uri.password.empty? ? URI.decode_www_form_component(uri.password) : nil
|
|
154
|
+
@username = url_user || (env_nonempty("TINA4_CACHE_USERNAME"))
|
|
155
|
+
@password = url_pass || (env_nonempty("TINA4_CACHE_PASSWORD"))
|
|
156
|
+
rescue URI::InvalidURIError
|
|
157
|
+
@host = "localhost"
|
|
158
|
+
@port = 6379
|
|
159
|
+
@db = 0
|
|
160
|
+
@username = env_nonempty("TINA4_CACHE_USERNAME")
|
|
161
|
+
@password = env_nonempty("TINA4_CACHE_PASSWORD")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def env_nonempty(key)
|
|
165
|
+
v = ENV[key]
|
|
166
|
+
v && !v.empty? ? v : nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Real AUTH+PING handshake so wrong credentials also fall back to file.
|
|
170
|
+
def probe
|
|
171
|
+
resp_command("PING") == "PONG"
|
|
172
|
+
rescue StandardError
|
|
173
|
+
false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Send a command using the raw RESP protocol over TCP. Returns the simple
|
|
177
|
+
# string / bulk string / integer as a String, or nil on miss/error.
|
|
178
|
+
def resp_command(*args)
|
|
179
|
+
cmd = +"*#{args.size}\r\n"
|
|
180
|
+
args.each do |arg|
|
|
181
|
+
s = arg.to_s
|
|
182
|
+
cmd << "$#{s.bytesize}\r\n#{s}\r\n"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
sock = TCPSocket.new(@host, @port)
|
|
186
|
+
sock.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVTIMEO, [5, 0].pack("l_2"))
|
|
187
|
+
|
|
188
|
+
if @password
|
|
189
|
+
auth = if @username
|
|
190
|
+
"*3\r\n$4\r\nAUTH\r\n$#{@username.bytesize}\r\n#{@username}\r\n" \
|
|
191
|
+
"$#{@password.bytesize}\r\n#{@password}\r\n"
|
|
192
|
+
else
|
|
193
|
+
"*2\r\n$4\r\nAUTH\r\n$#{@password.bytesize}\r\n#{@password}\r\n"
|
|
194
|
+
end
|
|
195
|
+
sock.write(auth)
|
|
196
|
+
unless sock.recv(1024).start_with?("+")
|
|
197
|
+
sock.close
|
|
198
|
+
return nil
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
if @db != 0
|
|
203
|
+
select_cmd = "*2\r\n$6\r\nSELECT\r\n$#{@db.to_s.bytesize}\r\n#{@db}\r\n"
|
|
204
|
+
sock.write(select_cmd)
|
|
205
|
+
sock.recv(1024)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
sock.write(cmd)
|
|
209
|
+
response = sock.recv(65_536)
|
|
210
|
+
sock.close
|
|
211
|
+
|
|
212
|
+
if response.nil? || response.empty?
|
|
213
|
+
nil
|
|
214
|
+
elsif response.start_with?("+")
|
|
215
|
+
response[1..].strip
|
|
216
|
+
elsif response.start_with?("$-1")
|
|
217
|
+
nil
|
|
218
|
+
elsif response.start_with?("$")
|
|
219
|
+
lines = response.split("\r\n")
|
|
220
|
+
lines[1]
|
|
221
|
+
elsif response.start_with?(":")
|
|
222
|
+
response[1..].strip
|
|
223
|
+
elsif response.start_with?("-")
|
|
224
|
+
nil
|
|
225
|
+
else
|
|
226
|
+
response.strip
|
|
227
|
+
end
|
|
228
|
+
rescue StandardError
|
|
229
|
+
nil
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "redis_backend"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
module CacheBackends
|
|
7
|
+
# Valkey backend (parity with Python _ValkeyBackend). Valkey speaks the
|
|
8
|
+
# Redis wire protocol, so it reuses the Redis client / raw-RESP transport
|
|
9
|
+
# and only reports a different name.
|
|
10
|
+
class ValkeyBackend < RedisBackend
|
|
11
|
+
def initialize(url: "valkey://localhost:6379", max_entries: 1000)
|
|
12
|
+
super(url: url.sub(%r{^valkey://}, "redis://"), max_entries: max_entries, name: "valkey")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cache_backends/base_backend"
|
|
4
|
+
require_relative "cache_backends/memory_backend"
|
|
5
|
+
require_relative "cache_backends/file_backend"
|
|
6
|
+
require_relative "cache_backends/redis_backend"
|
|
7
|
+
require_relative "cache_backends/valkey_backend"
|
|
8
|
+
require_relative "cache_backends/memcached_backend"
|
|
9
|
+
require_relative "cache_backends/mongo_backend"
|
|
10
|
+
require_relative "cache_backends/database_backend"
|
|
11
|
+
|
|
12
|
+
module Tina4
|
|
13
|
+
# Unified cache backend family (parity with Python tina4_python.cache).
|
|
14
|
+
#
|
|
15
|
+
# Backends are key/value stores with TTL semantics, selected via env vars:
|
|
16
|
+
#
|
|
17
|
+
# memory — in-process LRU cache (default, zero deps)
|
|
18
|
+
# file — JSON files in data/cache/
|
|
19
|
+
# redis — Redis (redis gem or raw RESP over TCP)
|
|
20
|
+
# valkey — Valkey (Redis wire protocol; reports "valkey")
|
|
21
|
+
# memcached — Memcached (zero-dep text protocol over TCP)
|
|
22
|
+
# mongodb — MongoDB TTL collection (requires the mongo gem)
|
|
23
|
+
# database — tina4_cache table in any Tina4-supported database
|
|
24
|
+
#
|
|
25
|
+
# Environment:
|
|
26
|
+
# TINA4_CACHE_BACKEND — memory|file|redis|valkey|memcached|mongodb|database
|
|
27
|
+
# TINA4_CACHE_URL — connection URL (redis/valkey/memcached/mongo) OR
|
|
28
|
+
# SQL URL for database (falls back to TINA4_DATABASE_URL)
|
|
29
|
+
# TINA4_CACHE_TTL — default TTL in seconds (default: 60)
|
|
30
|
+
# TINA4_CACHE_MAX_ENTRIES — max cached entries (default: 1000)
|
|
31
|
+
# TINA4_CACHE_DIR — file backend directory (default: data/cache)
|
|
32
|
+
# TINA4_CACHE_USERNAME / TINA4_CACHE_PASSWORD — credentials when not in the URL
|
|
33
|
+
module CacheBackends
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Create a cache backend from explicit params or env vars.
|
|
37
|
+
#
|
|
38
|
+
# Graceful degradation: if the configured backend's driver is missing or its
|
|
39
|
+
# service is unreachable, the factory logs a warning and falls back to the
|
|
40
|
+
# file backend (persistent, zero-dep, always available) — never a silent
|
|
41
|
+
# no-op cache.
|
|
42
|
+
#
|
|
43
|
+
# @param backend [String, nil]
|
|
44
|
+
# @param url [String, nil]
|
|
45
|
+
# @param max_entries [Integer, nil]
|
|
46
|
+
# @param cache_dir [String, nil]
|
|
47
|
+
# @return [Tina4::CacheBackends::BaseBackend]
|
|
48
|
+
def create_backend(backend: nil, url: nil, max_entries: nil, cache_dir: nil)
|
|
49
|
+
backend ||= ENV.fetch("TINA4_CACHE_BACKEND", "memory")
|
|
50
|
+
max_entries ||= (ENV["TINA4_CACHE_MAX_ENTRIES"] || "1000").to_i
|
|
51
|
+
backend = backend.to_s.downcase.strip
|
|
52
|
+
|
|
53
|
+
be =
|
|
54
|
+
case backend
|
|
55
|
+
when "redis"
|
|
56
|
+
RedisBackend.new(url: url || ENV.fetch("TINA4_CACHE_URL", "redis://localhost:6379"),
|
|
57
|
+
max_entries: max_entries)
|
|
58
|
+
when "valkey"
|
|
59
|
+
ValkeyBackend.new(url: url || ENV.fetch("TINA4_CACHE_URL", "valkey://localhost:6379"),
|
|
60
|
+
max_entries: max_entries)
|
|
61
|
+
when "memcached", "memcache"
|
|
62
|
+
MemcachedBackend.new(url: url || ENV.fetch("TINA4_CACHE_URL", "memcached://localhost:11211"),
|
|
63
|
+
max_entries: max_entries)
|
|
64
|
+
when "mongodb", "mongo"
|
|
65
|
+
MongoBackend.new(url: url || ENV.fetch("TINA4_CACHE_URL", "mongodb://localhost:27017"),
|
|
66
|
+
max_entries: max_entries)
|
|
67
|
+
when "database", "db"
|
|
68
|
+
DatabaseBackend.new(url: url, max_entries: max_entries)
|
|
69
|
+
when "file"
|
|
70
|
+
dir = cache_dir || ENV.fetch("TINA4_CACHE_DIR", "data/cache")
|
|
71
|
+
return FileBackend.new(cache_dir: dir, max_entries: max_entries)
|
|
72
|
+
else
|
|
73
|
+
return MemoryBackend.new(max_entries: max_entries)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
return be if be.available?
|
|
77
|
+
|
|
78
|
+
# Configured backend unusable — degrade to the file backend.
|
|
79
|
+
begin
|
|
80
|
+
Tina4::Log.warning(
|
|
81
|
+
"Cache backend '#{backend}' is unavailable " \
|
|
82
|
+
"(driver missing or service unreachable) — falling back to 'file'."
|
|
83
|
+
)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
# Logging must never break cache construction.
|
|
86
|
+
end
|
|
87
|
+
dir = cache_dir || ENV.fetch("TINA4_CACHE_DIR", "data/cache")
|
|
88
|
+
FileBackend.new(cache_dir: dir, max_entries: max_entries)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
data/lib/tina4/database.rb
CHANGED
|
@@ -223,6 +223,25 @@ module Tina4
|
|
|
223
223
|
@cache_misses = 0
|
|
224
224
|
@cache_mutex = Mutex.new
|
|
225
225
|
|
|
226
|
+
# Persistent mode may route through the unified CacheBackend (redis/
|
|
227
|
+
# valkey/memcached/mongodb/database via TINA4_DB_CACHE_BACKEND) so
|
|
228
|
+
# multiple instances can share one cache with global write-invalidation.
|
|
229
|
+
# Request-scoped mode always stays in-process (the @query_cache dict).
|
|
230
|
+
# The DatabaseResult is serialized to a JSON-friendly Hash before storing
|
|
231
|
+
# and reconstructed on read so shared backends work cross-instance.
|
|
232
|
+
@cache_backend = nil
|
|
233
|
+
if @cache_persistent
|
|
234
|
+
begin
|
|
235
|
+
@cache_backend = Tina4::CacheBackends.create_backend(
|
|
236
|
+
backend: ENV["TINA4_DB_CACHE_BACKEND"] || "memory",
|
|
237
|
+
url: ENV["TINA4_DB_CACHE_URL"],
|
|
238
|
+
max_entries: 1000
|
|
239
|
+
)
|
|
240
|
+
rescue StandardError
|
|
241
|
+
@cache_backend = nil # fall back to the in-process dict
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
226
245
|
# Register this connection so Tina4::Database.reset_request_caches can
|
|
227
246
|
# clear its request-scoped entries at the start of every HTTP request.
|
|
228
247
|
Tina4::Database.register_instance(self)
|
|
@@ -289,6 +308,19 @@ module Tina4
|
|
|
289
308
|
# ── Query Cache ──────────────────────────────────────────────
|
|
290
309
|
|
|
291
310
|
def cache_stats
|
|
311
|
+
if @cache_backend
|
|
312
|
+
bs = @cache_backend.stats
|
|
313
|
+
return {
|
|
314
|
+
enabled: @cache_enabled,
|
|
315
|
+
mode: cache_mode,
|
|
316
|
+
hits: @cache_hits,
|
|
317
|
+
misses: @cache_misses,
|
|
318
|
+
size: bs[:size],
|
|
319
|
+
backend: bs[:backend] || @cache_backend.name,
|
|
320
|
+
ttl: @cache_ttl
|
|
321
|
+
}
|
|
322
|
+
end
|
|
323
|
+
|
|
292
324
|
@cache_mutex.synchronize do
|
|
293
325
|
{
|
|
294
326
|
enabled: @cache_enabled,
|
|
@@ -303,6 +335,7 @@ module Tina4
|
|
|
303
335
|
end
|
|
304
336
|
|
|
305
337
|
def cache_clear
|
|
338
|
+
@cache_backend.clear if @cache_backend
|
|
306
339
|
@cache_mutex.synchronize do
|
|
307
340
|
@query_cache.clear
|
|
308
341
|
@cache_hits = 0
|
|
@@ -762,6 +795,13 @@ module Tina4
|
|
|
762
795
|
end
|
|
763
796
|
|
|
764
797
|
def cache_get(key)
|
|
798
|
+
# Shared backend (persistent + TINA4_DB_CACHE_BACKEND) — reconstruct the
|
|
799
|
+
# DatabaseResult from the serialized Hash so cross-instance reads work.
|
|
800
|
+
if @cache_backend
|
|
801
|
+
raw = @cache_backend.get(key)
|
|
802
|
+
return raw.is_a?(Hash) ? deserialize_result(raw) : nil
|
|
803
|
+
end
|
|
804
|
+
|
|
765
805
|
@cache_mutex.synchronize do
|
|
766
806
|
entry = @query_cache[key]
|
|
767
807
|
return nil unless entry
|
|
@@ -774,6 +814,11 @@ module Tina4
|
|
|
774
814
|
end
|
|
775
815
|
|
|
776
816
|
def cache_set(key, value)
|
|
817
|
+
if @cache_backend
|
|
818
|
+
@cache_backend.set(key, serialize_result(value), @cache_ttl)
|
|
819
|
+
return
|
|
820
|
+
end
|
|
821
|
+
|
|
777
822
|
@cache_mutex.synchronize do
|
|
778
823
|
@query_cache[key] = {
|
|
779
824
|
expires_at: Process.clock_gettime(Process::CLOCK_MONOTONIC) + @cache_ttl,
|
|
@@ -783,9 +828,59 @@ module Tina4
|
|
|
783
828
|
end
|
|
784
829
|
|
|
785
830
|
def cache_invalidate
|
|
831
|
+
@cache_backend.clear if @cache_backend
|
|
786
832
|
@cache_mutex.synchronize { @query_cache.clear }
|
|
787
833
|
end
|
|
788
834
|
|
|
835
|
+
# Flatten a DatabaseResult (or a single fetch_one Hash) into a JSON-friendly
|
|
836
|
+
# Hash for shared backends. fetch_one stores a plain Hash row, so we tag the
|
|
837
|
+
# shape so deserialize can return the right thing.
|
|
838
|
+
def serialize_result(value)
|
|
839
|
+
if value.is_a?(Tina4::DatabaseResult)
|
|
840
|
+
{
|
|
841
|
+
"kind" => "result",
|
|
842
|
+
"records" => value.records,
|
|
843
|
+
"count" => value.count,
|
|
844
|
+
"limit" => value.limit,
|
|
845
|
+
"offset" => value.offset,
|
|
846
|
+
"affected_rows" => value.affected_rows,
|
|
847
|
+
"last_id" => value.last_id
|
|
848
|
+
}
|
|
849
|
+
else
|
|
850
|
+
# fetch_one row (Hash) or nil
|
|
851
|
+
{ "kind" => "row", "row" => value }
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
# Reconstruct from a backend-cached Hash. JSON round-trips string keys, so
|
|
856
|
+
# accept both string and symbol keys. Records are re-symbolised so a cached
|
|
857
|
+
# result is byte-for-byte equivalent to an uncached fetch (driver rows use
|
|
858
|
+
# symbol keys) regardless of which backend stored it.
|
|
859
|
+
def deserialize_result(data)
|
|
860
|
+
kind = data["kind"] || data[:kind]
|
|
861
|
+
if kind == "row"
|
|
862
|
+
row = data["row"] || data[:row]
|
|
863
|
+
symbolize_row(row)
|
|
864
|
+
else
|
|
865
|
+
records = (data["records"] || data[:records] || []).map { |r| symbolize_row(r) }
|
|
866
|
+
Tina4::DatabaseResult.new(
|
|
867
|
+
records,
|
|
868
|
+
count: data["count"] || data[:count] || 0,
|
|
869
|
+
limit: data["limit"] || data[:limit] || 0,
|
|
870
|
+
offset: data["offset"] || data[:offset] || 0,
|
|
871
|
+
affected_rows: data["affected_rows"] || data[:affected_rows] || 0,
|
|
872
|
+
last_id: data["last_id"] || data[:last_id]
|
|
873
|
+
)
|
|
874
|
+
end
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# Driver rows use symbol keys; re-key a JSON-round-tripped Hash to match.
|
|
878
|
+
def symbolize_row(row)
|
|
879
|
+
return row unless row.is_a?(Hash)
|
|
880
|
+
|
|
881
|
+
row.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
882
|
+
end
|
|
883
|
+
|
|
789
884
|
def detect_driver(conn)
|
|
790
885
|
case conn.to_s.downcase
|
|
791
886
|
when /\.db$/, /\.sqlite/, /sqlite/
|