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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
@@ -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