tina4ruby 3.11.13 → 3.11.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +935 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -110
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -106
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2025 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +696 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -255
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +458 -458
- data/lib/tina4ruby.rb +4 -4
- metadata +3 -3
data/lib/tina4/database.rb
CHANGED
|
@@ -1,625 +1,625 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
require "json"
|
|
3
|
-
require "uri"
|
|
4
|
-
require "digest"
|
|
5
|
-
|
|
6
|
-
module Tina4
|
|
7
|
-
# Thread-safe connection pool with round-robin rotation.
|
|
8
|
-
# Connections are created lazily on first use.
|
|
9
|
-
class ConnectionPool
|
|
10
|
-
attr_reader :size
|
|
11
|
-
|
|
12
|
-
def initialize(pool_size, driver_factory:, connection_string:, username: nil, password: nil)
|
|
13
|
-
@pool_size = pool_size
|
|
14
|
-
@driver_factory = driver_factory
|
|
15
|
-
@connection_string = connection_string
|
|
16
|
-
@username = username
|
|
17
|
-
@password = password
|
|
18
|
-
@drivers = Array.new(pool_size) # nil slots — lazy creation
|
|
19
|
-
@index = 0
|
|
20
|
-
@mutex = Mutex.new
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Get the next driver via round-robin. Thread-safe.
|
|
24
|
-
def checkout
|
|
25
|
-
@mutex.synchronize do
|
|
26
|
-
idx = @index
|
|
27
|
-
@index = (@index + 1) % @pool_size
|
|
28
|
-
|
|
29
|
-
if @drivers[idx].nil?
|
|
30
|
-
driver = @driver_factory.call
|
|
31
|
-
driver.connect(@connection_string, username: @username, password: @password)
|
|
32
|
-
@drivers[idx] = driver
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
@drivers[idx]
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# Return a driver to the pool. Currently a no-op for round-robin.
|
|
40
|
-
def checkin(_driver)
|
|
41
|
-
# no-op
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# Close all active connections.
|
|
45
|
-
def close_all
|
|
46
|
-
@mutex.synchronize do
|
|
47
|
-
@drivers.each_with_index do |driver, i|
|
|
48
|
-
if driver
|
|
49
|
-
driver.close rescue nil
|
|
50
|
-
@drivers[i] = nil
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Number of connections that have been created.
|
|
57
|
-
def active_count
|
|
58
|
-
@mutex.synchronize do
|
|
59
|
-
@drivers.count { |d| !d.nil? }
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def size
|
|
64
|
-
@pool_size
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
class Database
|
|
69
|
-
attr_reader :driver, :driver_name, :connected, :pool
|
|
70
|
-
|
|
71
|
-
DRIVERS = {
|
|
72
|
-
"sqlite" => "Tina4::Drivers::SqliteDriver",
|
|
73
|
-
"sqlite3" => "Tina4::Drivers::SqliteDriver",
|
|
74
|
-
"postgres" => "Tina4::Drivers::PostgresDriver",
|
|
75
|
-
"postgresql" => "Tina4::Drivers::PostgresDriver",
|
|
76
|
-
"mysql" => "Tina4::Drivers::MysqlDriver",
|
|
77
|
-
"mssql" => "Tina4::Drivers::MssqlDriver",
|
|
78
|
-
"sqlserver" => "Tina4::Drivers::MssqlDriver",
|
|
79
|
-
"firebird" => "Tina4::Drivers::FirebirdDriver",
|
|
80
|
-
"mongodb" => "Tina4::Drivers::MongodbDriver",
|
|
81
|
-
"mongo" => "Tina4::Drivers::MongodbDriver",
|
|
82
|
-
"odbc" => "Tina4::Drivers::OdbcDriver"
|
|
83
|
-
}.freeze
|
|
84
|
-
|
|
85
|
-
# Static factory — cross-framework consistency: Database.create(url)
|
|
86
|
-
def self.create(url, username: "", password: "", pool: 0)
|
|
87
|
-
new(url, username: username.empty? ? nil : username,
|
|
88
|
-
password: password.empty? ? nil : password,
|
|
89
|
-
pool: pool)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Construct a Database from environment variables.
|
|
93
|
-
# Returns nil if the named env var is not set.
|
|
94
|
-
def self.from_env(env_key: "DATABASE_URL", pool: 0)
|
|
95
|
-
url = ENV[env_key]
|
|
96
|
-
return nil if url.nil? || url.strip.empty?
|
|
97
|
-
|
|
98
|
-
new(url,
|
|
99
|
-
username: ENV["DATABASE_USERNAME"],
|
|
100
|
-
password: ENV["DATABASE_PASSWORD"],
|
|
101
|
-
pool: pool)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: 0)
|
|
105
|
-
@connection_string = connection_string || ENV["DATABASE_URL"]
|
|
106
|
-
@username = username || ENV["DATABASE_USERNAME"]
|
|
107
|
-
@password = password || ENV["DATABASE_PASSWORD"]
|
|
108
|
-
@driver_name = driver_name || detect_driver(@connection_string)
|
|
109
|
-
@pool_size = pool # 0 = single connection, N>0 = N pooled connections
|
|
110
|
-
@connected = false
|
|
111
|
-
|
|
112
|
-
# Query cache — off by default, opt-in via TINA4_DB_CACHE=true
|
|
113
|
-
@cache_enabled = truthy?(ENV["TINA4_DB_CACHE"])
|
|
114
|
-
@cache_ttl = (ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
|
|
115
|
-
@query_cache = {} # key => { expires_at:, value: }
|
|
116
|
-
@cache_hits = 0
|
|
117
|
-
@cache_misses = 0
|
|
118
|
-
@cache_mutex = Mutex.new
|
|
119
|
-
|
|
120
|
-
if @pool_size > 0
|
|
121
|
-
# Pooled mode — create a ConnectionPool with lazy driver creation
|
|
122
|
-
@pool = ConnectionPool.new(
|
|
123
|
-
@pool_size,
|
|
124
|
-
driver_factory: method(:create_driver),
|
|
125
|
-
connection_string: @connection_string,
|
|
126
|
-
username: @username,
|
|
127
|
-
password: @password
|
|
128
|
-
)
|
|
129
|
-
@driver = nil
|
|
130
|
-
@connected = true
|
|
131
|
-
else
|
|
132
|
-
# Single-connection mode — current behavior
|
|
133
|
-
@pool = nil
|
|
134
|
-
@driver = create_driver
|
|
135
|
-
connect
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def connect
|
|
140
|
-
@driver.connect(@connection_string, username: @username, password: @password)
|
|
141
|
-
@connected = true
|
|
142
|
-
|
|
143
|
-
# Enable autocommit if TINA4_AUTOCOMMIT env var is set
|
|
144
|
-
if truthy?(ENV["TINA4_AUTOCOMMIT"]) && @driver.respond_to?(:autocommit=)
|
|
145
|
-
@driver.autocommit = true
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
Tina4::Log.info("Database connected: #{@driver_name}")
|
|
149
|
-
rescue => e
|
|
150
|
-
Tina4::Log.error("Database connection failed: #{e.message}")
|
|
151
|
-
@connected = false
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def close
|
|
155
|
-
if @pool
|
|
156
|
-
@pool.close_all
|
|
157
|
-
elsif @driver && @connected
|
|
158
|
-
@driver.close
|
|
159
|
-
end
|
|
160
|
-
@connected = false
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
# Get the current driver — from pool (round-robin) or single connection.
|
|
164
|
-
def current_driver
|
|
165
|
-
if @pool
|
|
166
|
-
@pool.checkout
|
|
167
|
-
else
|
|
168
|
-
@driver
|
|
169
|
-
end
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
# ── Query Cache ──────────────────────────────────────────────
|
|
173
|
-
|
|
174
|
-
def cache_stats
|
|
175
|
-
@cache_mutex.synchronize do
|
|
176
|
-
{
|
|
177
|
-
enabled: @cache_enabled,
|
|
178
|
-
hits: @cache_hits,
|
|
179
|
-
misses: @cache_misses,
|
|
180
|
-
size: @query_cache.size,
|
|
181
|
-
ttl: @cache_ttl
|
|
182
|
-
}
|
|
183
|
-
end
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def cache_clear
|
|
187
|
-
@cache_mutex.synchronize do
|
|
188
|
-
@query_cache.clear
|
|
189
|
-
@cache_hits = 0
|
|
190
|
-
@cache_misses = 0
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def fetch(sql, params = [], limit: 100, offset: nil)
|
|
195
|
-
offset ||= 0
|
|
196
|
-
drv = current_driver
|
|
197
|
-
|
|
198
|
-
effective_sql = sql
|
|
199
|
-
# Skip appending LIMIT if SQL already has one
|
|
200
|
-
has_limit = sql.upcase.split("--")[0].include?("LIMIT")
|
|
201
|
-
if limit && !has_limit
|
|
202
|
-
effective_sql = drv.apply_limit(effective_sql, limit, offset)
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
if @cache_enabled
|
|
206
|
-
key = cache_key(effective_sql, params)
|
|
207
|
-
cached = cache_get(key)
|
|
208
|
-
if cached
|
|
209
|
-
@cache_mutex.synchronize { @cache_hits += 1 }
|
|
210
|
-
return cached
|
|
211
|
-
end
|
|
212
|
-
result = drv.execute_query(effective_sql, params)
|
|
213
|
-
result = Tina4::DatabaseResult.new(result, sql: effective_sql, db: self)
|
|
214
|
-
cache_set(key, result)
|
|
215
|
-
@cache_mutex.synchronize { @cache_misses += 1 }
|
|
216
|
-
return result
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
rows = drv.execute_query(effective_sql, params)
|
|
220
|
-
Tina4::DatabaseResult.new(rows, sql: effective_sql, db: self)
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def fetch_one(sql, params = [])
|
|
224
|
-
if @cache_enabled
|
|
225
|
-
key = cache_key(sql + ":ONE", params)
|
|
226
|
-
cached = cache_get(key)
|
|
227
|
-
if cached
|
|
228
|
-
@cache_mutex.synchronize { @cache_hits += 1 }
|
|
229
|
-
return cached
|
|
230
|
-
end
|
|
231
|
-
result = fetch(sql, params, limit: 1)
|
|
232
|
-
value = result.first
|
|
233
|
-
cache_set(key, value)
|
|
234
|
-
@cache_mutex.synchronize { @cache_misses += 1 }
|
|
235
|
-
return value
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
result = fetch(sql, params, limit: 1)
|
|
239
|
-
result.first
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def insert(table, data)
|
|
243
|
-
cache_invalidate if @cache_enabled
|
|
244
|
-
drv = current_driver
|
|
245
|
-
|
|
246
|
-
# List of hashes — batch insert
|
|
247
|
-
if data.is_a?(Array)
|
|
248
|
-
return { success: true, affected_rows: 0 } if data.empty?
|
|
249
|
-
keys = data.first.keys.map(&:to_s)
|
|
250
|
-
placeholders = drv.placeholders(keys.length)
|
|
251
|
-
sql = "INSERT INTO #{table} (#{keys.join(', ')}) VALUES (#{placeholders})"
|
|
252
|
-
params_list = data.map { |row| keys.map { |k| row[k.to_sym] || row[k] } }
|
|
253
|
-
return execute_many(sql, params_list)
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
columns = data.keys.map(&:to_s)
|
|
257
|
-
placeholders = drv.placeholders(columns.length)
|
|
258
|
-
sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
|
|
259
|
-
drv.execute(sql, data.values)
|
|
260
|
-
{ success: true, last_id: drv.last_insert_id }
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
def update(table, data, filter = {}, params = nil)
|
|
264
|
-
cache_invalidate if @cache_enabled
|
|
265
|
-
drv = current_driver
|
|
266
|
-
|
|
267
|
-
# String filter with explicit params array
|
|
268
|
-
if filter.is_a?(String) && !params.nil?
|
|
269
|
-
set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
270
|
-
sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
|
|
271
|
-
sql += " WHERE #{filter}" unless filter.empty?
|
|
272
|
-
drv.execute(sql, data.values + Array(params))
|
|
273
|
-
return { success: true }
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
277
|
-
where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
278
|
-
sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
|
|
279
|
-
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
280
|
-
values = data.values + filter.values
|
|
281
|
-
drv.execute(sql, values)
|
|
282
|
-
{ success: true }
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
def delete(table, filter = {}, params = nil)
|
|
286
|
-
cache_invalidate if @cache_enabled
|
|
287
|
-
drv = current_driver
|
|
288
|
-
|
|
289
|
-
# List of hashes — delete each row
|
|
290
|
-
if filter.is_a?(Array)
|
|
291
|
-
filter.each { |row| delete(table, row) }
|
|
292
|
-
return { success: true }
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
# String filter — raw WHERE clause with optional params
|
|
296
|
-
if filter.is_a?(String)
|
|
297
|
-
sql = "DELETE FROM #{table}"
|
|
298
|
-
sql += " WHERE #{filter}" unless filter.empty?
|
|
299
|
-
drv.execute(sql, Array(params))
|
|
300
|
-
return { success: true }
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
# Hash filter — build WHERE from keys
|
|
304
|
-
where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
305
|
-
sql = "DELETE FROM #{table}"
|
|
306
|
-
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
307
|
-
drv.execute(sql, filter.values)
|
|
308
|
-
{ success: true }
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
# Return the last execute() error message, or nil.
|
|
312
|
-
def get_error
|
|
313
|
-
@last_error
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
# Return the last insert ID from execute() or insert().
|
|
317
|
-
def get_last_id
|
|
318
|
-
current_driver.last_insert_id
|
|
319
|
-
rescue
|
|
320
|
-
nil
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
# Execute a write statement. Returns true/false for simple writes.
|
|
324
|
-
# Returns DatabaseResult if SQL contains RETURNING, CALL, EXEC, or SELECT.
|
|
325
|
-
def execute(sql, params = [])
|
|
326
|
-
cache_invalidate if @cache_enabled
|
|
327
|
-
result = current_driver.execute(sql, params)
|
|
328
|
-
@last_error = nil
|
|
329
|
-
sql_upper = sql.strip.upcase
|
|
330
|
-
if sql_upper.include?("RETURNING") || sql_upper.start_with?("CALL ") ||
|
|
331
|
-
sql_upper.start_with?("EXEC ") || sql_upper.start_with?("SELECT ")
|
|
332
|
-
return result
|
|
333
|
-
end
|
|
334
|
-
true
|
|
335
|
-
rescue => e
|
|
336
|
-
@last_error = e.message
|
|
337
|
-
false
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
def execute_many(sql, params_list = [])
|
|
341
|
-
results = []
|
|
342
|
-
drv = current_driver
|
|
343
|
-
drv.begin_transaction
|
|
344
|
-
begin
|
|
345
|
-
params_list.each do |params|
|
|
346
|
-
results << drv.execute(sql, params)
|
|
347
|
-
end
|
|
348
|
-
drv.commit
|
|
349
|
-
rescue => e
|
|
350
|
-
drv.rollback
|
|
351
|
-
raise e
|
|
352
|
-
end
|
|
353
|
-
results
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
def transaction
|
|
357
|
-
drv = current_driver
|
|
358
|
-
drv.begin_transaction
|
|
359
|
-
yield self
|
|
360
|
-
drv.commit
|
|
361
|
-
rescue => e
|
|
362
|
-
drv.rollback
|
|
363
|
-
raise e
|
|
364
|
-
end
|
|
365
|
-
|
|
366
|
-
# Begin a transaction without a block — matches PHP/Python/Node API.
|
|
367
|
-
def start_transaction
|
|
368
|
-
current_driver.begin_transaction
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
# Commit the current transaction — matches PHP/Python/Node API.
|
|
372
|
-
def commit
|
|
373
|
-
current_driver.commit
|
|
374
|
-
end
|
|
375
|
-
|
|
376
|
-
# Roll back the current transaction — matches PHP/Python/Node API.
|
|
377
|
-
def rollback
|
|
378
|
-
current_driver.rollback
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
def tables
|
|
382
|
-
current_driver.tables
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
# Cross-framework alias for tables — matches PHP/Python/Node get_tables.
|
|
386
|
-
alias get_tables tables
|
|
387
|
-
|
|
388
|
-
def columns(table_name)
|
|
389
|
-
current_driver.columns(table_name)
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
# Cross-framework alias for columns — matches PHP/Python/Node get_columns.
|
|
393
|
-
alias get_columns columns
|
|
394
|
-
|
|
395
|
-
def table_exists?(table_name)
|
|
396
|
-
tables.any? { |t| t.downcase == table_name.to_s.downcase }
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
# Cross-framework alias for table_exists? — matches PHP/Python/Node table_exists.
|
|
400
|
-
alias table_exists table_exists?
|
|
401
|
-
|
|
402
|
-
# Pre-generate the next available primary key ID using engine-aware strategies.
|
|
403
|
-
#
|
|
404
|
-
# Race-safe implementation using a `tina4_sequences` table for SQLite/MySQL/MSSQL
|
|
405
|
-
# fallback. Each call atomically increments the stored counter, so concurrent
|
|
406
|
-
# callers never receive the same value.
|
|
407
|
-
#
|
|
408
|
-
# - Firebird: auto-creates a generator if missing, then increments via GEN_ID.
|
|
409
|
-
# - PostgreSQL: tries nextval() on the named sequence, auto-creates it if missing.
|
|
410
|
-
# - SQLite/MySQL/MSSQL: atomic UPDATE on `tina4_sequences` table.
|
|
411
|
-
# - Returns 1 if the table is empty or does not exist.
|
|
412
|
-
#
|
|
413
|
-
# @param table [String] Table name
|
|
414
|
-
# @param pk_column [String] Primary key column name (default: "id")
|
|
415
|
-
# @param generator_name [String, nil] Override for sequence/generator name
|
|
416
|
-
# @return [Integer] The next available ID
|
|
417
|
-
# Returns the underlying driver object (pool's current driver or single driver).
|
|
418
|
-
def get_adapter
|
|
419
|
-
current_driver
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
# Returns the configured pool size, or 1 for single-connection mode.
|
|
423
|
-
def pool_size
|
|
424
|
-
@pool_size > 0 ? @pool_size : 1
|
|
425
|
-
end
|
|
426
|
-
|
|
427
|
-
# Number of connections currently created (lazy pool connections counted).
|
|
428
|
-
def active_count
|
|
429
|
-
if @pool
|
|
430
|
-
@pool.active_count
|
|
431
|
-
else
|
|
432
|
-
@connected ? 1 : 0
|
|
433
|
-
end
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
# Check out a driver from the pool (or return the single driver).
|
|
437
|
-
def checkout
|
|
438
|
-
current_driver
|
|
439
|
-
end
|
|
440
|
-
|
|
441
|
-
# Return a driver to the pool. No-op for round-robin pool or single connection.
|
|
442
|
-
def checkin(_driver)
|
|
443
|
-
# no-op
|
|
444
|
-
end
|
|
445
|
-
|
|
446
|
-
# Close all pooled connections (or the single connection).
|
|
447
|
-
def close_all
|
|
448
|
-
close
|
|
449
|
-
end
|
|
450
|
-
|
|
451
|
-
def get_next_id(table, pk_column: "id", generator_name: nil)
|
|
452
|
-
drv = current_driver
|
|
453
|
-
|
|
454
|
-
# Firebird — use generators
|
|
455
|
-
if @driver_name == "firebird"
|
|
456
|
-
gen_name = generator_name || "GEN_#{table.upcase}_ID"
|
|
457
|
-
|
|
458
|
-
# Auto-create the generator if it does not exist
|
|
459
|
-
begin
|
|
460
|
-
drv.execute("CREATE GENERATOR #{gen_name}")
|
|
461
|
-
rescue
|
|
462
|
-
# Generator already exists — ignore
|
|
463
|
-
end
|
|
464
|
-
|
|
465
|
-
rows = drv.execute_query("SELECT GEN_ID(#{gen_name}, 1) AS NEXT_ID FROM RDB$DATABASE")
|
|
466
|
-
row = rows.is_a?(Array) ? rows.first : nil
|
|
467
|
-
val = row_value(row, :NEXT_ID) || row_value(row, :next_id)
|
|
468
|
-
return val&.to_i || 1
|
|
469
|
-
end
|
|
470
|
-
|
|
471
|
-
# PostgreSQL — try sequence first, auto-create if missing
|
|
472
|
-
if @driver_name == "postgres"
|
|
473
|
-
seq_name = generator_name || "#{table.downcase}_#{pk_column.downcase}_seq"
|
|
474
|
-
begin
|
|
475
|
-
rows = drv.execute_query("SELECT nextval('#{seq_name}') AS next_id")
|
|
476
|
-
row = rows.is_a?(Array) ? rows.first : nil
|
|
477
|
-
val = row_value(row, :next_id) || row_value(row, :nextval)
|
|
478
|
-
return val.to_i if val
|
|
479
|
-
rescue
|
|
480
|
-
# Sequence does not exist — auto-create it seeded from MAX
|
|
481
|
-
begin
|
|
482
|
-
max_rows = drv.execute_query("SELECT COALESCE(MAX(#{pk_column}), 0) AS max_id FROM #{table}")
|
|
483
|
-
max_row = max_rows.is_a?(Array) ? max_rows.first : nil
|
|
484
|
-
max_val = row_value(max_row, :max_id)
|
|
485
|
-
start_val = max_val ? max_val.to_i + 1 : 1
|
|
486
|
-
drv.execute("CREATE SEQUENCE #{seq_name} START WITH #{start_val}")
|
|
487
|
-
drv.commit rescue nil
|
|
488
|
-
rows = drv.execute_query("SELECT nextval('#{seq_name}') AS next_id")
|
|
489
|
-
row = rows.is_a?(Array) ? rows.first : nil
|
|
490
|
-
val = row_value(row, :next_id) || row_value(row, :nextval)
|
|
491
|
-
return val&.to_i || start_val
|
|
492
|
-
rescue
|
|
493
|
-
# Fall through to sequence table fallback
|
|
494
|
-
end
|
|
495
|
-
end
|
|
496
|
-
end
|
|
497
|
-
|
|
498
|
-
# SQLite / MySQL / MSSQL / PostgreSQL fallback — atomic sequence table
|
|
499
|
-
seq_key = generator_name || "#{table}.#{pk_column}"
|
|
500
|
-
sequence_next(seq_key, table: table, pk_column: pk_column)
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
private
|
|
504
|
-
|
|
505
|
-
# Ensure the tina4_sequences table exists for race-safe ID generation.
|
|
506
|
-
def ensure_sequence_table
|
|
507
|
-
return if table_exists?("tina4_sequences")
|
|
508
|
-
|
|
509
|
-
drv = current_driver
|
|
510
|
-
if @driver_name == "mssql"
|
|
511
|
-
drv.execute("CREATE TABLE tina4_sequences (seq_name VARCHAR(200) NOT NULL PRIMARY KEY, current_value INTEGER NOT NULL DEFAULT 0)")
|
|
512
|
-
else
|
|
513
|
-
drv.execute("CREATE TABLE IF NOT EXISTS tina4_sequences (seq_name VARCHAR(200) NOT NULL PRIMARY KEY, current_value INTEGER NOT NULL DEFAULT 0)")
|
|
514
|
-
end
|
|
515
|
-
drv.commit rescue nil
|
|
516
|
-
end
|
|
517
|
-
|
|
518
|
-
# Atomically increment and return the next value for a named sequence.
|
|
519
|
-
# Seeds from MAX(pk_column) on first use so existing data is respected.
|
|
520
|
-
def sequence_next(seq_name, table: nil, pk_column: "id")
|
|
521
|
-
ensure_sequence_table
|
|
522
|
-
drv = current_driver
|
|
523
|
-
|
|
524
|
-
# Check if the sequence key already exists
|
|
525
|
-
rows = drv.execute_query("SELECT current_value FROM tina4_sequences WHERE seq_name = ?", [seq_name])
|
|
526
|
-
row = rows.is_a?(Array) ? rows.first : nil
|
|
527
|
-
|
|
528
|
-
if row.nil?
|
|
529
|
-
# Seed from MAX(pk_column) if table data exists
|
|
530
|
-
seed_value = 0
|
|
531
|
-
if table
|
|
532
|
-
begin
|
|
533
|
-
max_rows = drv.execute_query("SELECT MAX(#{pk_column}) AS max_id FROM #{table}")
|
|
534
|
-
max_row = max_rows.is_a?(Array) ? max_rows.first : nil
|
|
535
|
-
val = row_value(max_row, :max_id)
|
|
536
|
-
seed_value = val.to_i if val
|
|
537
|
-
rescue
|
|
538
|
-
# Table may not exist yet — start from 0
|
|
539
|
-
end
|
|
540
|
-
end
|
|
541
|
-
drv.execute("INSERT INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)", [seq_name, seed_value])
|
|
542
|
-
drv.commit rescue nil
|
|
543
|
-
end
|
|
544
|
-
|
|
545
|
-
# Atomic increment
|
|
546
|
-
drv.execute("UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?", [seq_name])
|
|
547
|
-
drv.commit rescue nil
|
|
548
|
-
|
|
549
|
-
# Read back the incremented value
|
|
550
|
-
rows = drv.execute_query("SELECT current_value FROM tina4_sequences WHERE seq_name = ?", [seq_name])
|
|
551
|
-
row = rows.is_a?(Array) ? rows.first : nil
|
|
552
|
-
val = row_value(row, :current_value)
|
|
553
|
-
val ? val.to_i : 1
|
|
554
|
-
end
|
|
555
|
-
|
|
556
|
-
# Safely extract a value from a driver result row, trying both symbol and string keys.
|
|
557
|
-
def row_value(row, key)
|
|
558
|
-
return nil unless row
|
|
559
|
-
row[key.to_sym] || row[key.to_s] || row[key.to_s.upcase] || row[key.to_s.downcase]
|
|
560
|
-
end
|
|
561
|
-
|
|
562
|
-
def truthy?(val)
|
|
563
|
-
%w[true 1 yes on].include?((val || "").to_s.strip.downcase)
|
|
564
|
-
end
|
|
565
|
-
|
|
566
|
-
def cache_key(sql, params)
|
|
567
|
-
Digest::SHA256.hexdigest(sql + params.to_s)
|
|
568
|
-
end
|
|
569
|
-
|
|
570
|
-
def cache_get(key)
|
|
571
|
-
@cache_mutex.synchronize do
|
|
572
|
-
entry = @query_cache[key]
|
|
573
|
-
return nil unless entry
|
|
574
|
-
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > entry[:expires_at]
|
|
575
|
-
@query_cache.delete(key)
|
|
576
|
-
return nil
|
|
577
|
-
end
|
|
578
|
-
entry[:value]
|
|
579
|
-
end
|
|
580
|
-
end
|
|
581
|
-
|
|
582
|
-
def cache_set(key, value)
|
|
583
|
-
@cache_mutex.synchronize do
|
|
584
|
-
@query_cache[key] = {
|
|
585
|
-
expires_at: Process.clock_gettime(Process::CLOCK_MONOTONIC) + @cache_ttl,
|
|
586
|
-
value: value
|
|
587
|
-
}
|
|
588
|
-
end
|
|
589
|
-
end
|
|
590
|
-
|
|
591
|
-
def cache_invalidate
|
|
592
|
-
@cache_mutex.synchronize { @query_cache.clear }
|
|
593
|
-
end
|
|
594
|
-
|
|
595
|
-
def detect_driver(conn)
|
|
596
|
-
case conn.to_s.downcase
|
|
597
|
-
when /\.db$/, /\.sqlite/, /sqlite/
|
|
598
|
-
"sqlite"
|
|
599
|
-
when /postgres/, /^pg:/
|
|
600
|
-
"postgres"
|
|
601
|
-
when /mysql/
|
|
602
|
-
"mysql"
|
|
603
|
-
when /mssql/, /sqlserver/
|
|
604
|
-
"mssql"
|
|
605
|
-
when /firebird/, /\.fdb$/
|
|
606
|
-
"firebird"
|
|
607
|
-
when /mongodb/, /^mongo:/
|
|
608
|
-
"mongodb"
|
|
609
|
-
when /^odbc:/
|
|
610
|
-
"odbc"
|
|
611
|
-
else
|
|
612
|
-
"sqlite"
|
|
613
|
-
end
|
|
614
|
-
end
|
|
615
|
-
|
|
616
|
-
def create_driver
|
|
617
|
-
klass_name = DRIVERS[@driver_name]
|
|
618
|
-
raise "Unknown database driver: #{@driver_name}" unless klass_name
|
|
619
|
-
klass = Object.const_get(klass_name)
|
|
620
|
-
klass.new
|
|
621
|
-
rescue NameError
|
|
622
|
-
raise "Driver #{klass_name} not loaded. Install the required gem."
|
|
623
|
-
end
|
|
624
|
-
end
|
|
625
|
-
end
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Tina4
|
|
7
|
+
# Thread-safe connection pool with round-robin rotation.
|
|
8
|
+
# Connections are created lazily on first use.
|
|
9
|
+
class ConnectionPool
|
|
10
|
+
attr_reader :size
|
|
11
|
+
|
|
12
|
+
def initialize(pool_size, driver_factory:, connection_string:, username: nil, password: nil)
|
|
13
|
+
@pool_size = pool_size
|
|
14
|
+
@driver_factory = driver_factory
|
|
15
|
+
@connection_string = connection_string
|
|
16
|
+
@username = username
|
|
17
|
+
@password = password
|
|
18
|
+
@drivers = Array.new(pool_size) # nil slots — lazy creation
|
|
19
|
+
@index = 0
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Get the next driver via round-robin. Thread-safe.
|
|
24
|
+
def checkout
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
idx = @index
|
|
27
|
+
@index = (@index + 1) % @pool_size
|
|
28
|
+
|
|
29
|
+
if @drivers[idx].nil?
|
|
30
|
+
driver = @driver_factory.call
|
|
31
|
+
driver.connect(@connection_string, username: @username, password: @password)
|
|
32
|
+
@drivers[idx] = driver
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@drivers[idx]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Return a driver to the pool. Currently a no-op for round-robin.
|
|
40
|
+
def checkin(_driver)
|
|
41
|
+
# no-op
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Close all active connections.
|
|
45
|
+
def close_all
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
@drivers.each_with_index do |driver, i|
|
|
48
|
+
if driver
|
|
49
|
+
driver.close rescue nil
|
|
50
|
+
@drivers[i] = nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Number of connections that have been created.
|
|
57
|
+
def active_count
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
@drivers.count { |d| !d.nil? }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def size
|
|
64
|
+
@pool_size
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class Database
|
|
69
|
+
attr_reader :driver, :driver_name, :connected, :pool
|
|
70
|
+
|
|
71
|
+
DRIVERS = {
|
|
72
|
+
"sqlite" => "Tina4::Drivers::SqliteDriver",
|
|
73
|
+
"sqlite3" => "Tina4::Drivers::SqliteDriver",
|
|
74
|
+
"postgres" => "Tina4::Drivers::PostgresDriver",
|
|
75
|
+
"postgresql" => "Tina4::Drivers::PostgresDriver",
|
|
76
|
+
"mysql" => "Tina4::Drivers::MysqlDriver",
|
|
77
|
+
"mssql" => "Tina4::Drivers::MssqlDriver",
|
|
78
|
+
"sqlserver" => "Tina4::Drivers::MssqlDriver",
|
|
79
|
+
"firebird" => "Tina4::Drivers::FirebirdDriver",
|
|
80
|
+
"mongodb" => "Tina4::Drivers::MongodbDriver",
|
|
81
|
+
"mongo" => "Tina4::Drivers::MongodbDriver",
|
|
82
|
+
"odbc" => "Tina4::Drivers::OdbcDriver"
|
|
83
|
+
}.freeze
|
|
84
|
+
|
|
85
|
+
# Static factory — cross-framework consistency: Database.create(url)
|
|
86
|
+
def self.create(url, username: "", password: "", pool: 0)
|
|
87
|
+
new(url, username: username.empty? ? nil : username,
|
|
88
|
+
password: password.empty? ? nil : password,
|
|
89
|
+
pool: pool)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Construct a Database from environment variables.
|
|
93
|
+
# Returns nil if the named env var is not set.
|
|
94
|
+
def self.from_env(env_key: "DATABASE_URL", pool: 0)
|
|
95
|
+
url = ENV[env_key]
|
|
96
|
+
return nil if url.nil? || url.strip.empty?
|
|
97
|
+
|
|
98
|
+
new(url,
|
|
99
|
+
username: ENV["DATABASE_USERNAME"],
|
|
100
|
+
password: ENV["DATABASE_PASSWORD"],
|
|
101
|
+
pool: pool)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: 0)
|
|
105
|
+
@connection_string = connection_string || ENV["DATABASE_URL"]
|
|
106
|
+
@username = username || ENV["DATABASE_USERNAME"]
|
|
107
|
+
@password = password || ENV["DATABASE_PASSWORD"]
|
|
108
|
+
@driver_name = driver_name || detect_driver(@connection_string)
|
|
109
|
+
@pool_size = pool # 0 = single connection, N>0 = N pooled connections
|
|
110
|
+
@connected = false
|
|
111
|
+
|
|
112
|
+
# Query cache — off by default, opt-in via TINA4_DB_CACHE=true
|
|
113
|
+
@cache_enabled = truthy?(ENV["TINA4_DB_CACHE"])
|
|
114
|
+
@cache_ttl = (ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
|
|
115
|
+
@query_cache = {} # key => { expires_at:, value: }
|
|
116
|
+
@cache_hits = 0
|
|
117
|
+
@cache_misses = 0
|
|
118
|
+
@cache_mutex = Mutex.new
|
|
119
|
+
|
|
120
|
+
if @pool_size > 0
|
|
121
|
+
# Pooled mode — create a ConnectionPool with lazy driver creation
|
|
122
|
+
@pool = ConnectionPool.new(
|
|
123
|
+
@pool_size,
|
|
124
|
+
driver_factory: method(:create_driver),
|
|
125
|
+
connection_string: @connection_string,
|
|
126
|
+
username: @username,
|
|
127
|
+
password: @password
|
|
128
|
+
)
|
|
129
|
+
@driver = nil
|
|
130
|
+
@connected = true
|
|
131
|
+
else
|
|
132
|
+
# Single-connection mode — current behavior
|
|
133
|
+
@pool = nil
|
|
134
|
+
@driver = create_driver
|
|
135
|
+
connect
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def connect
|
|
140
|
+
@driver.connect(@connection_string, username: @username, password: @password)
|
|
141
|
+
@connected = true
|
|
142
|
+
|
|
143
|
+
# Enable autocommit if TINA4_AUTOCOMMIT env var is set
|
|
144
|
+
if truthy?(ENV["TINA4_AUTOCOMMIT"]) && @driver.respond_to?(:autocommit=)
|
|
145
|
+
@driver.autocommit = true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
Tina4::Log.info("Database connected: #{@driver_name}")
|
|
149
|
+
rescue => e
|
|
150
|
+
Tina4::Log.error("Database connection failed: #{e.message}")
|
|
151
|
+
@connected = false
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def close
|
|
155
|
+
if @pool
|
|
156
|
+
@pool.close_all
|
|
157
|
+
elsif @driver && @connected
|
|
158
|
+
@driver.close
|
|
159
|
+
end
|
|
160
|
+
@connected = false
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Get the current driver — from pool (round-robin) or single connection.
|
|
164
|
+
def current_driver
|
|
165
|
+
if @pool
|
|
166
|
+
@pool.checkout
|
|
167
|
+
else
|
|
168
|
+
@driver
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# ── Query Cache ──────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
def cache_stats
|
|
175
|
+
@cache_mutex.synchronize do
|
|
176
|
+
{
|
|
177
|
+
enabled: @cache_enabled,
|
|
178
|
+
hits: @cache_hits,
|
|
179
|
+
misses: @cache_misses,
|
|
180
|
+
size: @query_cache.size,
|
|
181
|
+
ttl: @cache_ttl
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def cache_clear
|
|
187
|
+
@cache_mutex.synchronize do
|
|
188
|
+
@query_cache.clear
|
|
189
|
+
@cache_hits = 0
|
|
190
|
+
@cache_misses = 0
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def fetch(sql, params = [], limit: 100, offset: nil)
|
|
195
|
+
offset ||= 0
|
|
196
|
+
drv = current_driver
|
|
197
|
+
|
|
198
|
+
effective_sql = sql
|
|
199
|
+
# Skip appending LIMIT if SQL already has one
|
|
200
|
+
has_limit = sql.upcase.split("--")[0].include?("LIMIT")
|
|
201
|
+
if limit && !has_limit
|
|
202
|
+
effective_sql = drv.apply_limit(effective_sql, limit, offset)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
if @cache_enabled
|
|
206
|
+
key = cache_key(effective_sql, params)
|
|
207
|
+
cached = cache_get(key)
|
|
208
|
+
if cached
|
|
209
|
+
@cache_mutex.synchronize { @cache_hits += 1 }
|
|
210
|
+
return cached
|
|
211
|
+
end
|
|
212
|
+
result = drv.execute_query(effective_sql, params)
|
|
213
|
+
result = Tina4::DatabaseResult.new(result, sql: effective_sql, db: self)
|
|
214
|
+
cache_set(key, result)
|
|
215
|
+
@cache_mutex.synchronize { @cache_misses += 1 }
|
|
216
|
+
return result
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
rows = drv.execute_query(effective_sql, params)
|
|
220
|
+
Tina4::DatabaseResult.new(rows, sql: effective_sql, db: self)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def fetch_one(sql, params = [])
|
|
224
|
+
if @cache_enabled
|
|
225
|
+
key = cache_key(sql + ":ONE", params)
|
|
226
|
+
cached = cache_get(key)
|
|
227
|
+
if cached
|
|
228
|
+
@cache_mutex.synchronize { @cache_hits += 1 }
|
|
229
|
+
return cached
|
|
230
|
+
end
|
|
231
|
+
result = fetch(sql, params, limit: 1)
|
|
232
|
+
value = result.first
|
|
233
|
+
cache_set(key, value)
|
|
234
|
+
@cache_mutex.synchronize { @cache_misses += 1 }
|
|
235
|
+
return value
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
result = fetch(sql, params, limit: 1)
|
|
239
|
+
result.first
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def insert(table, data)
|
|
243
|
+
cache_invalidate if @cache_enabled
|
|
244
|
+
drv = current_driver
|
|
245
|
+
|
|
246
|
+
# List of hashes — batch insert
|
|
247
|
+
if data.is_a?(Array)
|
|
248
|
+
return { success: true, affected_rows: 0 } if data.empty?
|
|
249
|
+
keys = data.first.keys.map(&:to_s)
|
|
250
|
+
placeholders = drv.placeholders(keys.length)
|
|
251
|
+
sql = "INSERT INTO #{table} (#{keys.join(', ')}) VALUES (#{placeholders})"
|
|
252
|
+
params_list = data.map { |row| keys.map { |k| row[k.to_sym] || row[k] } }
|
|
253
|
+
return execute_many(sql, params_list)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
columns = data.keys.map(&:to_s)
|
|
257
|
+
placeholders = drv.placeholders(columns.length)
|
|
258
|
+
sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
|
|
259
|
+
drv.execute(sql, data.values)
|
|
260
|
+
{ success: true, last_id: drv.last_insert_id }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def update(table, data, filter = {}, params = nil)
|
|
264
|
+
cache_invalidate if @cache_enabled
|
|
265
|
+
drv = current_driver
|
|
266
|
+
|
|
267
|
+
# String filter with explicit params array
|
|
268
|
+
if filter.is_a?(String) && !params.nil?
|
|
269
|
+
set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
270
|
+
sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
|
|
271
|
+
sql += " WHERE #{filter}" unless filter.empty?
|
|
272
|
+
drv.execute(sql, data.values + Array(params))
|
|
273
|
+
return { success: true }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
277
|
+
where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
278
|
+
sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
|
|
279
|
+
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
280
|
+
values = data.values + filter.values
|
|
281
|
+
drv.execute(sql, values)
|
|
282
|
+
{ success: true }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def delete(table, filter = {}, params = nil)
|
|
286
|
+
cache_invalidate if @cache_enabled
|
|
287
|
+
drv = current_driver
|
|
288
|
+
|
|
289
|
+
# List of hashes — delete each row
|
|
290
|
+
if filter.is_a?(Array)
|
|
291
|
+
filter.each { |row| delete(table, row) }
|
|
292
|
+
return { success: true }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# String filter — raw WHERE clause with optional params
|
|
296
|
+
if filter.is_a?(String)
|
|
297
|
+
sql = "DELETE FROM #{table}"
|
|
298
|
+
sql += " WHERE #{filter}" unless filter.empty?
|
|
299
|
+
drv.execute(sql, Array(params))
|
|
300
|
+
return { success: true }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Hash filter — build WHERE from keys
|
|
304
|
+
where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
305
|
+
sql = "DELETE FROM #{table}"
|
|
306
|
+
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
307
|
+
drv.execute(sql, filter.values)
|
|
308
|
+
{ success: true }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Return the last execute() error message, or nil.
|
|
312
|
+
def get_error
|
|
313
|
+
@last_error
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Return the last insert ID from execute() or insert().
|
|
317
|
+
def get_last_id
|
|
318
|
+
current_driver.last_insert_id
|
|
319
|
+
rescue
|
|
320
|
+
nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Execute a write statement. Returns true/false for simple writes.
|
|
324
|
+
# Returns DatabaseResult if SQL contains RETURNING, CALL, EXEC, or SELECT.
|
|
325
|
+
def execute(sql, params = [])
|
|
326
|
+
cache_invalidate if @cache_enabled
|
|
327
|
+
result = current_driver.execute(sql, params)
|
|
328
|
+
@last_error = nil
|
|
329
|
+
sql_upper = sql.strip.upcase
|
|
330
|
+
if sql_upper.include?("RETURNING") || sql_upper.start_with?("CALL ") ||
|
|
331
|
+
sql_upper.start_with?("EXEC ") || sql_upper.start_with?("SELECT ")
|
|
332
|
+
return result
|
|
333
|
+
end
|
|
334
|
+
true
|
|
335
|
+
rescue => e
|
|
336
|
+
@last_error = e.message
|
|
337
|
+
false
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def execute_many(sql, params_list = [])
|
|
341
|
+
results = []
|
|
342
|
+
drv = current_driver
|
|
343
|
+
drv.begin_transaction
|
|
344
|
+
begin
|
|
345
|
+
params_list.each do |params|
|
|
346
|
+
results << drv.execute(sql, params)
|
|
347
|
+
end
|
|
348
|
+
drv.commit
|
|
349
|
+
rescue => e
|
|
350
|
+
drv.rollback
|
|
351
|
+
raise e
|
|
352
|
+
end
|
|
353
|
+
results
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def transaction
|
|
357
|
+
drv = current_driver
|
|
358
|
+
drv.begin_transaction
|
|
359
|
+
yield self
|
|
360
|
+
drv.commit
|
|
361
|
+
rescue => e
|
|
362
|
+
drv.rollback
|
|
363
|
+
raise e
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Begin a transaction without a block — matches PHP/Python/Node API.
|
|
367
|
+
def start_transaction
|
|
368
|
+
current_driver.begin_transaction
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Commit the current transaction — matches PHP/Python/Node API.
|
|
372
|
+
def commit
|
|
373
|
+
current_driver.commit
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Roll back the current transaction — matches PHP/Python/Node API.
|
|
377
|
+
def rollback
|
|
378
|
+
current_driver.rollback
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def tables
|
|
382
|
+
current_driver.tables
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Cross-framework alias for tables — matches PHP/Python/Node get_tables.
|
|
386
|
+
alias get_tables tables
|
|
387
|
+
|
|
388
|
+
def columns(table_name)
|
|
389
|
+
current_driver.columns(table_name)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Cross-framework alias for columns — matches PHP/Python/Node get_columns.
|
|
393
|
+
alias get_columns columns
|
|
394
|
+
|
|
395
|
+
def table_exists?(table_name)
|
|
396
|
+
tables.any? { |t| t.downcase == table_name.to_s.downcase }
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Cross-framework alias for table_exists? — matches PHP/Python/Node table_exists.
|
|
400
|
+
alias table_exists table_exists?
|
|
401
|
+
|
|
402
|
+
# Pre-generate the next available primary key ID using engine-aware strategies.
|
|
403
|
+
#
|
|
404
|
+
# Race-safe implementation using a `tina4_sequences` table for SQLite/MySQL/MSSQL
|
|
405
|
+
# fallback. Each call atomically increments the stored counter, so concurrent
|
|
406
|
+
# callers never receive the same value.
|
|
407
|
+
#
|
|
408
|
+
# - Firebird: auto-creates a generator if missing, then increments via GEN_ID.
|
|
409
|
+
# - PostgreSQL: tries nextval() on the named sequence, auto-creates it if missing.
|
|
410
|
+
# - SQLite/MySQL/MSSQL: atomic UPDATE on `tina4_sequences` table.
|
|
411
|
+
# - Returns 1 if the table is empty or does not exist.
|
|
412
|
+
#
|
|
413
|
+
# @param table [String] Table name
|
|
414
|
+
# @param pk_column [String] Primary key column name (default: "id")
|
|
415
|
+
# @param generator_name [String, nil] Override for sequence/generator name
|
|
416
|
+
# @return [Integer] The next available ID
|
|
417
|
+
# Returns the underlying driver object (pool's current driver or single driver).
|
|
418
|
+
def get_adapter
|
|
419
|
+
current_driver
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Returns the configured pool size, or 1 for single-connection mode.
|
|
423
|
+
def pool_size
|
|
424
|
+
@pool_size > 0 ? @pool_size : 1
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Number of connections currently created (lazy pool connections counted).
|
|
428
|
+
def active_count
|
|
429
|
+
if @pool
|
|
430
|
+
@pool.active_count
|
|
431
|
+
else
|
|
432
|
+
@connected ? 1 : 0
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Check out a driver from the pool (or return the single driver).
|
|
437
|
+
def checkout
|
|
438
|
+
current_driver
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Return a driver to the pool. No-op for round-robin pool or single connection.
|
|
442
|
+
def checkin(_driver)
|
|
443
|
+
# no-op
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Close all pooled connections (or the single connection).
|
|
447
|
+
def close_all
|
|
448
|
+
close
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def get_next_id(table, pk_column: "id", generator_name: nil)
|
|
452
|
+
drv = current_driver
|
|
453
|
+
|
|
454
|
+
# Firebird — use generators
|
|
455
|
+
if @driver_name == "firebird"
|
|
456
|
+
gen_name = generator_name || "GEN_#{table.upcase}_ID"
|
|
457
|
+
|
|
458
|
+
# Auto-create the generator if it does not exist
|
|
459
|
+
begin
|
|
460
|
+
drv.execute("CREATE GENERATOR #{gen_name}")
|
|
461
|
+
rescue
|
|
462
|
+
# Generator already exists — ignore
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
rows = drv.execute_query("SELECT GEN_ID(#{gen_name}, 1) AS NEXT_ID FROM RDB$DATABASE")
|
|
466
|
+
row = rows.is_a?(Array) ? rows.first : nil
|
|
467
|
+
val = row_value(row, :NEXT_ID) || row_value(row, :next_id)
|
|
468
|
+
return val&.to_i || 1
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# PostgreSQL — try sequence first, auto-create if missing
|
|
472
|
+
if @driver_name == "postgres"
|
|
473
|
+
seq_name = generator_name || "#{table.downcase}_#{pk_column.downcase}_seq"
|
|
474
|
+
begin
|
|
475
|
+
rows = drv.execute_query("SELECT nextval('#{seq_name}') AS next_id")
|
|
476
|
+
row = rows.is_a?(Array) ? rows.first : nil
|
|
477
|
+
val = row_value(row, :next_id) || row_value(row, :nextval)
|
|
478
|
+
return val.to_i if val
|
|
479
|
+
rescue
|
|
480
|
+
# Sequence does not exist — auto-create it seeded from MAX
|
|
481
|
+
begin
|
|
482
|
+
max_rows = drv.execute_query("SELECT COALESCE(MAX(#{pk_column}), 0) AS max_id FROM #{table}")
|
|
483
|
+
max_row = max_rows.is_a?(Array) ? max_rows.first : nil
|
|
484
|
+
max_val = row_value(max_row, :max_id)
|
|
485
|
+
start_val = max_val ? max_val.to_i + 1 : 1
|
|
486
|
+
drv.execute("CREATE SEQUENCE #{seq_name} START WITH #{start_val}")
|
|
487
|
+
drv.commit rescue nil
|
|
488
|
+
rows = drv.execute_query("SELECT nextval('#{seq_name}') AS next_id")
|
|
489
|
+
row = rows.is_a?(Array) ? rows.first : nil
|
|
490
|
+
val = row_value(row, :next_id) || row_value(row, :nextval)
|
|
491
|
+
return val&.to_i || start_val
|
|
492
|
+
rescue
|
|
493
|
+
# Fall through to sequence table fallback
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# SQLite / MySQL / MSSQL / PostgreSQL fallback — atomic sequence table
|
|
499
|
+
seq_key = generator_name || "#{table}.#{pk_column}"
|
|
500
|
+
sequence_next(seq_key, table: table, pk_column: pk_column)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
private
|
|
504
|
+
|
|
505
|
+
# Ensure the tina4_sequences table exists for race-safe ID generation.
|
|
506
|
+
def ensure_sequence_table
|
|
507
|
+
return if table_exists?("tina4_sequences")
|
|
508
|
+
|
|
509
|
+
drv = current_driver
|
|
510
|
+
if @driver_name == "mssql"
|
|
511
|
+
drv.execute("CREATE TABLE tina4_sequences (seq_name VARCHAR(200) NOT NULL PRIMARY KEY, current_value INTEGER NOT NULL DEFAULT 0)")
|
|
512
|
+
else
|
|
513
|
+
drv.execute("CREATE TABLE IF NOT EXISTS tina4_sequences (seq_name VARCHAR(200) NOT NULL PRIMARY KEY, current_value INTEGER NOT NULL DEFAULT 0)")
|
|
514
|
+
end
|
|
515
|
+
drv.commit rescue nil
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Atomically increment and return the next value for a named sequence.
|
|
519
|
+
# Seeds from MAX(pk_column) on first use so existing data is respected.
|
|
520
|
+
def sequence_next(seq_name, table: nil, pk_column: "id")
|
|
521
|
+
ensure_sequence_table
|
|
522
|
+
drv = current_driver
|
|
523
|
+
|
|
524
|
+
# Check if the sequence key already exists
|
|
525
|
+
rows = drv.execute_query("SELECT current_value FROM tina4_sequences WHERE seq_name = ?", [seq_name])
|
|
526
|
+
row = rows.is_a?(Array) ? rows.first : nil
|
|
527
|
+
|
|
528
|
+
if row.nil?
|
|
529
|
+
# Seed from MAX(pk_column) if table data exists
|
|
530
|
+
seed_value = 0
|
|
531
|
+
if table
|
|
532
|
+
begin
|
|
533
|
+
max_rows = drv.execute_query("SELECT MAX(#{pk_column}) AS max_id FROM #{table}")
|
|
534
|
+
max_row = max_rows.is_a?(Array) ? max_rows.first : nil
|
|
535
|
+
val = row_value(max_row, :max_id)
|
|
536
|
+
seed_value = val.to_i if val
|
|
537
|
+
rescue
|
|
538
|
+
# Table may not exist yet — start from 0
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
drv.execute("INSERT INTO tina4_sequences (seq_name, current_value) VALUES (?, ?)", [seq_name, seed_value])
|
|
542
|
+
drv.commit rescue nil
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Atomic increment
|
|
546
|
+
drv.execute("UPDATE tina4_sequences SET current_value = current_value + 1 WHERE seq_name = ?", [seq_name])
|
|
547
|
+
drv.commit rescue nil
|
|
548
|
+
|
|
549
|
+
# Read back the incremented value
|
|
550
|
+
rows = drv.execute_query("SELECT current_value FROM tina4_sequences WHERE seq_name = ?", [seq_name])
|
|
551
|
+
row = rows.is_a?(Array) ? rows.first : nil
|
|
552
|
+
val = row_value(row, :current_value)
|
|
553
|
+
val ? val.to_i : 1
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Safely extract a value from a driver result row, trying both symbol and string keys.
|
|
557
|
+
def row_value(row, key)
|
|
558
|
+
return nil unless row
|
|
559
|
+
row[key.to_sym] || row[key.to_s] || row[key.to_s.upcase] || row[key.to_s.downcase]
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def truthy?(val)
|
|
563
|
+
%w[true 1 yes on].include?((val || "").to_s.strip.downcase)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def cache_key(sql, params)
|
|
567
|
+
Digest::SHA256.hexdigest(sql + params.to_s)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def cache_get(key)
|
|
571
|
+
@cache_mutex.synchronize do
|
|
572
|
+
entry = @query_cache[key]
|
|
573
|
+
return nil unless entry
|
|
574
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > entry[:expires_at]
|
|
575
|
+
@query_cache.delete(key)
|
|
576
|
+
return nil
|
|
577
|
+
end
|
|
578
|
+
entry[:value]
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def cache_set(key, value)
|
|
583
|
+
@cache_mutex.synchronize do
|
|
584
|
+
@query_cache[key] = {
|
|
585
|
+
expires_at: Process.clock_gettime(Process::CLOCK_MONOTONIC) + @cache_ttl,
|
|
586
|
+
value: value
|
|
587
|
+
}
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def cache_invalidate
|
|
592
|
+
@cache_mutex.synchronize { @query_cache.clear }
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def detect_driver(conn)
|
|
596
|
+
case conn.to_s.downcase
|
|
597
|
+
when /\.db$/, /\.sqlite/, /sqlite/
|
|
598
|
+
"sqlite"
|
|
599
|
+
when /postgres/, /^pg:/
|
|
600
|
+
"postgres"
|
|
601
|
+
when /mysql/
|
|
602
|
+
"mysql"
|
|
603
|
+
when /mssql/, /sqlserver/
|
|
604
|
+
"mssql"
|
|
605
|
+
when /firebird/, /\.fdb$/
|
|
606
|
+
"firebird"
|
|
607
|
+
when /mongodb/, /^mongo:/
|
|
608
|
+
"mongodb"
|
|
609
|
+
when /^odbc:/
|
|
610
|
+
"odbc"
|
|
611
|
+
else
|
|
612
|
+
"sqlite"
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def create_driver
|
|
617
|
+
klass_name = DRIVERS[@driver_name]
|
|
618
|
+
raise "Unknown database driver: #{@driver_name}" unless klass_name
|
|
619
|
+
klass = Object.const_get(klass_name)
|
|
620
|
+
klass.new
|
|
621
|
+
rescue NameError
|
|
622
|
+
raise "Driver #{klass_name} not loaded. Install the required gem."
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
end
|