tina4ruby 3.0.0 → 3.9.2
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/README.md +120 -32
- data/lib/tina4/auth.rb +137 -27
- data/lib/tina4/auto_crud.rb +55 -3
- data/lib/tina4/cli.rb +228 -28
- data/lib/tina4/cors.rb +1 -1
- data/lib/tina4/database.rb +230 -26
- data/lib/tina4/database_result.rb +122 -8
- data/lib/tina4/dev_mailbox.rb +1 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/frond.rb +314 -7
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +314 -16
- data/lib/tina4/localization.rb +1 -1
- data/lib/tina4/messenger.rb +111 -33
- data/lib/tina4/middleware.rb +349 -1
- data/lib/tina4/migration.rb +132 -11
- data/lib/tina4/orm.rb +149 -18
- data/lib/tina4/public/js/tina4-dev-admin.min.js +1 -1
- data/lib/tina4/public/js/tina4js.min.js +47 -0
- data/lib/tina4/query_builder.rb +374 -0
- data/lib/tina4/queue.rb +219 -61
- data/lib/tina4/queue_backends/lite_backend.rb +42 -7
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -0
- data/lib/tina4/rack_app.rb +200 -11
- data/lib/tina4/request.rb +14 -1
- data/lib/tina4/response.rb +26 -0
- data/lib/tina4/response_cache.rb +446 -29
- data/lib/tina4/router.rb +127 -0
- data/lib/tina4/service_runner.rb +1 -1
- data/lib/tina4/session.rb +6 -1
- data/lib/tina4/session_handlers/database_handler.rb +66 -0
- data/lib/tina4/swagger.rb +1 -1
- data/lib/tina4/templates/errors/404.twig +2 -2
- data/lib/tina4/templates/errors/500.twig +1 -1
- data/lib/tina4/validator.rb +174 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +23 -4
- data/lib/tina4/websocket_backplane.rb +118 -0
- data/lib/tina4.rb +126 -5
- metadata +40 -3
data/lib/tina4/cli.rb
CHANGED
|
@@ -5,7 +5,7 @@ require "fileutils"
|
|
|
5
5
|
|
|
6
6
|
module Tina4
|
|
7
7
|
class CLI
|
|
8
|
-
COMMANDS = %w[init start migrate seed seed:create test version routes console ai help].freeze
|
|
8
|
+
COMMANDS = %w[init start migrate migrate:status migrate:rollback seed seed:create test version routes console generate ai help].freeze
|
|
9
9
|
|
|
10
10
|
def self.start(argv)
|
|
11
11
|
new.run(argv)
|
|
@@ -17,12 +17,15 @@ module Tina4
|
|
|
17
17
|
when "init" then cmd_init(argv)
|
|
18
18
|
when "start" then cmd_start(argv)
|
|
19
19
|
when "migrate" then cmd_migrate(argv)
|
|
20
|
+
when "migrate:status" then cmd_migrate_status(argv)
|
|
21
|
+
when "migrate:rollback" then cmd_migrate_rollback(argv)
|
|
20
22
|
when "seed" then cmd_seed(argv)
|
|
21
23
|
when "seed:create" then cmd_seed_create(argv)
|
|
22
24
|
when "test" then cmd_test(argv)
|
|
23
25
|
when "version" then cmd_version
|
|
24
26
|
when "routes" then cmd_routes
|
|
25
27
|
when "console" then cmd_console
|
|
28
|
+
when "generate" then cmd_generate(argv)
|
|
26
29
|
when "ai" then cmd_ai(argv)
|
|
27
30
|
when "help", "-h", "--help" then cmd_help
|
|
28
31
|
else
|
|
@@ -97,37 +100,45 @@ module Tina4
|
|
|
97
100
|
|
|
98
101
|
app = Tina4::RackApp.new(root_dir: root_dir)
|
|
99
102
|
|
|
103
|
+
is_debug = Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
|
|
104
|
+
|
|
100
105
|
# Try Puma first (production-grade), fall back to WEBrick
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
106
|
+
# In debug mode, always use WEBrick for dev toolbar/reload support
|
|
107
|
+
if !is_debug
|
|
108
|
+
begin
|
|
109
|
+
require "puma"
|
|
110
|
+
require "puma/configuration"
|
|
111
|
+
require "puma/launcher"
|
|
112
|
+
|
|
113
|
+
puma_host = options[:host]
|
|
114
|
+
puma_port = options[:port]
|
|
115
|
+
|
|
116
|
+
config = Puma::Configuration.new do |user_config|
|
|
117
|
+
user_config.bind "tcp://#{puma_host}:#{puma_port}"
|
|
118
|
+
user_config.app app
|
|
119
|
+
user_config.threads 0, 16
|
|
120
|
+
user_config.workers 0
|
|
121
|
+
user_config.environment "production"
|
|
122
|
+
user_config.log_requests false
|
|
123
|
+
user_config.quiet
|
|
124
|
+
end
|
|
118
125
|
|
|
119
|
-
|
|
126
|
+
Tina4::Log.info("Production server: puma")
|
|
120
127
|
|
|
121
|
-
|
|
122
|
-
|
|
128
|
+
# Setup graceful shutdown (Puma manages its own signals, but we handle DB cleanup)
|
|
129
|
+
Tina4::Shutdown.setup
|
|
123
130
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
131
|
+
launcher = Puma::Launcher.new(config)
|
|
132
|
+
launcher.run
|
|
133
|
+
return
|
|
134
|
+
rescue LoadError
|
|
135
|
+
# Puma not installed, fall through to WEBrick
|
|
136
|
+
end
|
|
130
137
|
end
|
|
138
|
+
|
|
139
|
+
Tina4::Log.info("Development server: WEBrick")
|
|
140
|
+
server = Tina4::WebServer.new(app, host: options[:host], port: options[:port])
|
|
141
|
+
server.start
|
|
131
142
|
end
|
|
132
143
|
|
|
133
144
|
# ── migrate ───────────────────────────────────────────────────────────
|
|
@@ -171,6 +182,75 @@ module Tina4
|
|
|
171
182
|
end
|
|
172
183
|
end
|
|
173
184
|
|
|
185
|
+
# ── migrate:status ─────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def cmd_migrate_status(_argv)
|
|
188
|
+
require_relative "../tina4"
|
|
189
|
+
Tina4.initialize!(Dir.pwd)
|
|
190
|
+
|
|
191
|
+
db = Tina4.database
|
|
192
|
+
unless db
|
|
193
|
+
puts "No database configured. Set DATABASE_URL in your .env file."
|
|
194
|
+
return
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
migration = Tina4::Migration.new(db)
|
|
198
|
+
info = migration.status
|
|
199
|
+
|
|
200
|
+
puts "\nMigration Status"
|
|
201
|
+
puts "-" * 60
|
|
202
|
+
|
|
203
|
+
if info[:completed].any?
|
|
204
|
+
puts "\nCompleted:"
|
|
205
|
+
info[:completed].each { |name| puts " [OK] #{name}" }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if info[:pending].any?
|
|
209
|
+
puts "\nPending:"
|
|
210
|
+
info[:pending].each { |name| puts " [ ] #{name}" }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
if info[:completed].empty? && info[:pending].empty?
|
|
214
|
+
puts " No migrations found."
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
puts "-" * 60
|
|
218
|
+
puts " Completed: #{info[:completed].length} Pending: #{info[:pending].length}\n"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# ── migrate:rollback ───────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
def cmd_migrate_rollback(argv)
|
|
224
|
+
options = { steps: 1 }
|
|
225
|
+
parser = OptionParser.new do |opts|
|
|
226
|
+
opts.banner = "Usage: tina4ruby migrate:rollback [options]"
|
|
227
|
+
opts.on("-n", "--steps N", Integer, "Number of batches to rollback (default: 1)") { |v| options[:steps] = v }
|
|
228
|
+
end
|
|
229
|
+
parser.parse!(argv)
|
|
230
|
+
|
|
231
|
+
require_relative "../tina4"
|
|
232
|
+
Tina4.initialize!(Dir.pwd)
|
|
233
|
+
|
|
234
|
+
db = Tina4.database
|
|
235
|
+
unless db
|
|
236
|
+
puts "No database configured. Set DATABASE_URL in your .env file."
|
|
237
|
+
return
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
migration = Tina4::Migration.new(db)
|
|
241
|
+
results = migration.rollback(options[:steps])
|
|
242
|
+
|
|
243
|
+
if results.empty?
|
|
244
|
+
puts "Nothing to rollback."
|
|
245
|
+
else
|
|
246
|
+
results.each do |r|
|
|
247
|
+
status_icon = r[:status] == "rolled_back" ? "OK" : "FAIL"
|
|
248
|
+
puts " [#{status_icon}] #{r[:name]}"
|
|
249
|
+
end
|
|
250
|
+
puts "Rolled back #{results.length} migration(s)."
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
174
254
|
# ── seed ──────────────────────────────────────────────────────────────
|
|
175
255
|
|
|
176
256
|
def cmd_seed(argv)
|
|
@@ -310,6 +390,123 @@ module Tina4
|
|
|
310
390
|
|
|
311
391
|
# ── help ──────────────────────────────────────────────────────────────
|
|
312
392
|
|
|
393
|
+
# ── generate ────────────────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
def cmd_generate(argv)
|
|
396
|
+
what = argv.shift
|
|
397
|
+
name = argv.shift
|
|
398
|
+
unless what && name
|
|
399
|
+
puts "Usage: tina4ruby generate <what> <name>"
|
|
400
|
+
puts " Generators: model, route, migration, middleware"
|
|
401
|
+
exit 1
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
case what
|
|
405
|
+
when "model" then generate_model(name)
|
|
406
|
+
when "route" then generate_route(name)
|
|
407
|
+
when "migration" then generate_migration(name)
|
|
408
|
+
when "middleware" then generate_middleware(name)
|
|
409
|
+
else
|
|
410
|
+
puts "Unknown generator: #{what}"
|
|
411
|
+
puts " Available: model, route, migration, middleware"
|
|
412
|
+
exit 1
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def generate_model(name)
|
|
417
|
+
dir = "src/orm"
|
|
418
|
+
FileUtils.mkdir_p(dir)
|
|
419
|
+
snake = name.gsub(/([A-Z])/) { |m| ($~.begin(0) > 0 ? "_" : "") + m.downcase }
|
|
420
|
+
path = File.join(dir, "#{snake}.rb")
|
|
421
|
+
abort " File already exists: #{path}" if File.exist?(path)
|
|
422
|
+
File.write(path, <<~RUBY)
|
|
423
|
+
class #{name} < Tina4::ORM
|
|
424
|
+
integer_field :id, primary_key: true, auto_increment: true
|
|
425
|
+
string_field :name
|
|
426
|
+
string_field :email
|
|
427
|
+
end
|
|
428
|
+
RUBY
|
|
429
|
+
puts " Created #{path}"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def generate_route(name)
|
|
433
|
+
route_path = name.sub(%r{^/}, "")
|
|
434
|
+
dir = "src/routes/#{route_path}"
|
|
435
|
+
FileUtils.mkdir_p(dir)
|
|
436
|
+
path = dir.chomp("/") + ".rb"
|
|
437
|
+
abort " File already exists: #{path}" if File.exist?(path)
|
|
438
|
+
File.write(path, <<~RUBY)
|
|
439
|
+
Tina4.get "/#{route_path}" do |request, response|
|
|
440
|
+
response.json(data: [])
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
Tina4.get "/#{route_path}/:id" do |request, response|
|
|
444
|
+
response.json(data: {})
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
Tina4.post "/#{route_path}" do |request, response|
|
|
448
|
+
response.json({ message: "created" }, 201)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
Tina4.put "/#{route_path}/:id" do |request, response|
|
|
452
|
+
response.json(message: "updated")
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
Tina4.delete "/#{route_path}/:id" do |request, response|
|
|
456
|
+
response.json(message: "deleted")
|
|
457
|
+
end
|
|
458
|
+
RUBY
|
|
459
|
+
puts " Created #{path}"
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def generate_migration(name)
|
|
463
|
+
dir = "migrations"
|
|
464
|
+
FileUtils.mkdir_p(dir)
|
|
465
|
+
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
|
|
466
|
+
table = name.sub(/^create_/, "")
|
|
467
|
+
table = if table.end_with?("s")
|
|
468
|
+
table
|
|
469
|
+
elsif table.end_with?("y")
|
|
470
|
+
table[0..-2] + "ies"
|
|
471
|
+
else
|
|
472
|
+
table + "s"
|
|
473
|
+
end
|
|
474
|
+
filename = "#{timestamp}_#{name}.sql"
|
|
475
|
+
path = File.join(dir, filename)
|
|
476
|
+
now = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
|
477
|
+
File.write(path, <<~SQL)
|
|
478
|
+
-- Migration: #{name}
|
|
479
|
+
-- Created: #{now}
|
|
480
|
+
|
|
481
|
+
CREATE TABLE #{table} (
|
|
482
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
483
|
+
name TEXT NOT NULL,
|
|
484
|
+
email TEXT NOT NULL,
|
|
485
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
486
|
+
);
|
|
487
|
+
SQL
|
|
488
|
+
puts " Created #{path}"
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def generate_middleware(name)
|
|
492
|
+
dir = "src/middleware"
|
|
493
|
+
FileUtils.mkdir_p(dir)
|
|
494
|
+
snake = name.gsub(/([A-Z])/) { |m| ($~.begin(0) > 0 ? "_" : "") + m.downcase }
|
|
495
|
+
path = File.join(dir, "#{snake}.rb")
|
|
496
|
+
abort " File already exists: #{path}" if File.exist?(path)
|
|
497
|
+
File.write(path, <<~RUBY)
|
|
498
|
+
class #{name} < Tina4::Middleware
|
|
499
|
+
def process(request, response)
|
|
500
|
+
auth = request.headers["Authorization"]
|
|
501
|
+
return response.json({ error: "Unauthorized" }, 401) unless auth
|
|
502
|
+
|
|
503
|
+
nil
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
RUBY
|
|
507
|
+
puts " Created #{path}"
|
|
508
|
+
end
|
|
509
|
+
|
|
313
510
|
def cmd_help
|
|
314
511
|
puts <<~HELP
|
|
315
512
|
Tina4 Ruby CLI
|
|
@@ -320,12 +517,15 @@ module Tina4
|
|
|
320
517
|
init [NAME] Initialize a new Tina4 project
|
|
321
518
|
start Start the Tina4 web server
|
|
322
519
|
migrate Run database migrations
|
|
520
|
+
migrate:status Show migration status (completed and pending)
|
|
521
|
+
migrate:rollback Rollback the last batch of migrations
|
|
323
522
|
seed Run all seed files in seeds/
|
|
324
523
|
seed:create NAME Create a new seed file
|
|
325
524
|
test Run inline tests
|
|
326
525
|
version Show Tina4 version
|
|
327
526
|
routes List all registered routes
|
|
328
527
|
console Start an interactive console
|
|
528
|
+
generate <what> <name> Generate scaffolding (model, route, migration, middleware)
|
|
329
529
|
ai Detect AI tools and install context files
|
|
330
530
|
help Show this help message
|
|
331
531
|
|
|
@@ -355,7 +555,7 @@ module Tina4
|
|
|
355
555
|
# ── shared helpers ────────────────────────────────────────────────────
|
|
356
556
|
|
|
357
557
|
def load_routes(root_dir)
|
|
358
|
-
route_dirs = %w[
|
|
558
|
+
route_dirs = %w[src/routes routes src/api api src/orm orm]
|
|
359
559
|
route_dirs.each do |dir|
|
|
360
560
|
route_dir = File.join(root_dir, dir)
|
|
361
561
|
next unless Dir.exist?(route_dir)
|
data/lib/tina4/cors.rb
CHANGED
|
@@ -52,7 +52,7 @@ module Tina4
|
|
|
52
52
|
{
|
|
53
53
|
origins: ENV["TINA4_CORS_ORIGINS"] || "*",
|
|
54
54
|
methods: ENV["TINA4_CORS_METHODS"] || "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
55
|
-
headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,
|
|
55
|
+
headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,Authorization,X-Request-ID",
|
|
56
56
|
max_age: ENV["TINA4_CORS_MAX_AGE"] || "86400",
|
|
57
57
|
credentials: ENV["TINA4_CORS_CREDENTIALS"] || "false"
|
|
58
58
|
}.freeze
|
data/lib/tina4/database.rb
CHANGED
|
@@ -1,10 +1,72 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "json"
|
|
3
3
|
require "uri"
|
|
4
|
+
require "digest"
|
|
4
5
|
|
|
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
|
+
|
|
6
68
|
class Database
|
|
7
|
-
attr_reader :driver, :driver_name, :connected
|
|
69
|
+
attr_reader :driver, :driver_name, :connected, :pool
|
|
8
70
|
|
|
9
71
|
DRIVERS = {
|
|
10
72
|
"sqlite" => "Tina4::Drivers::SqliteDriver",
|
|
@@ -17,19 +79,50 @@ module Tina4
|
|
|
17
79
|
"firebird" => "Tina4::Drivers::FirebirdDriver"
|
|
18
80
|
}.freeze
|
|
19
81
|
|
|
20
|
-
def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil)
|
|
82
|
+
def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: 0)
|
|
21
83
|
@connection_string = connection_string || ENV["DATABASE_URL"]
|
|
22
84
|
@username = username || ENV["DATABASE_USERNAME"]
|
|
23
85
|
@password = password || ENV["DATABASE_PASSWORD"]
|
|
24
86
|
@driver_name = driver_name || detect_driver(@connection_string)
|
|
25
|
-
@
|
|
87
|
+
@pool_size = pool # 0 = single connection, N>0 = N pooled connections
|
|
26
88
|
@connected = false
|
|
27
|
-
|
|
89
|
+
|
|
90
|
+
# Query cache — off by default, opt-in via TINA4_DB_CACHE=true
|
|
91
|
+
@cache_enabled = truthy?(ENV["TINA4_DB_CACHE"])
|
|
92
|
+
@cache_ttl = (ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
|
|
93
|
+
@query_cache = {} # key => { expires_at:, value: }
|
|
94
|
+
@cache_hits = 0
|
|
95
|
+
@cache_misses = 0
|
|
96
|
+
@cache_mutex = Mutex.new
|
|
97
|
+
|
|
98
|
+
if @pool_size > 0
|
|
99
|
+
# Pooled mode — create a ConnectionPool with lazy driver creation
|
|
100
|
+
@pool = ConnectionPool.new(
|
|
101
|
+
@pool_size,
|
|
102
|
+
driver_factory: method(:create_driver),
|
|
103
|
+
connection_string: @connection_string,
|
|
104
|
+
username: @username,
|
|
105
|
+
password: @password
|
|
106
|
+
)
|
|
107
|
+
@driver = nil
|
|
108
|
+
@connected = true
|
|
109
|
+
else
|
|
110
|
+
# Single-connection mode — current behavior
|
|
111
|
+
@pool = nil
|
|
112
|
+
@driver = create_driver
|
|
113
|
+
connect
|
|
114
|
+
end
|
|
28
115
|
end
|
|
29
116
|
|
|
30
117
|
def connect
|
|
31
118
|
@driver.connect(@connection_string, username: @username, password: @password)
|
|
32
119
|
@connected = true
|
|
120
|
+
|
|
121
|
+
# Enable autocommit if TINA4_AUTOCOMMIT env var is set
|
|
122
|
+
if truthy?(ENV["TINA4_AUTOCOMMIT"]) && @driver.respond_to?(:autocommit=)
|
|
123
|
+
@driver.autocommit = true
|
|
124
|
+
end
|
|
125
|
+
|
|
33
126
|
Tina4::Log.info("Database connected: #{@driver_name}")
|
|
34
127
|
rescue => e
|
|
35
128
|
Tina4::Log.error("Database connection failed: #{e.message}")
|
|
@@ -37,53 +130,129 @@ module Tina4
|
|
|
37
130
|
end
|
|
38
131
|
|
|
39
132
|
def close
|
|
40
|
-
|
|
133
|
+
if @pool
|
|
134
|
+
@pool.close_all
|
|
135
|
+
elsif @driver && @connected
|
|
136
|
+
@driver.close
|
|
137
|
+
end
|
|
41
138
|
@connected = false
|
|
42
139
|
end
|
|
43
140
|
|
|
44
|
-
|
|
141
|
+
# Get the current driver — from pool (round-robin) or single connection.
|
|
142
|
+
def current_driver
|
|
143
|
+
if @pool
|
|
144
|
+
@pool.checkout
|
|
145
|
+
else
|
|
146
|
+
@driver
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# ── Query Cache ──────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
def cache_stats
|
|
153
|
+
@cache_mutex.synchronize do
|
|
154
|
+
{
|
|
155
|
+
enabled: @cache_enabled,
|
|
156
|
+
hits: @cache_hits,
|
|
157
|
+
misses: @cache_misses,
|
|
158
|
+
size: @query_cache.size,
|
|
159
|
+
ttl: @cache_ttl
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def cache_clear
|
|
165
|
+
@cache_mutex.synchronize do
|
|
166
|
+
@query_cache.clear
|
|
167
|
+
@cache_hits = 0
|
|
168
|
+
@cache_misses = 0
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def fetch(sql, params = [], limit: nil, offset: nil)
|
|
173
|
+
offset ||= 0
|
|
174
|
+
drv = current_driver
|
|
175
|
+
|
|
45
176
|
effective_sql = sql
|
|
46
177
|
if limit
|
|
47
|
-
effective_sql =
|
|
178
|
+
effective_sql = drv.apply_limit(effective_sql, limit, offset)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
if @cache_enabled
|
|
182
|
+
key = cache_key(effective_sql, params)
|
|
183
|
+
cached = cache_get(key)
|
|
184
|
+
if cached
|
|
185
|
+
@cache_mutex.synchronize { @cache_hits += 1 }
|
|
186
|
+
return cached
|
|
187
|
+
end
|
|
188
|
+
result = drv.execute_query(effective_sql, params)
|
|
189
|
+
result = Tina4::DatabaseResult.new(result, sql: effective_sql, db: self)
|
|
190
|
+
cache_set(key, result)
|
|
191
|
+
@cache_mutex.synchronize { @cache_misses += 1 }
|
|
192
|
+
return result
|
|
48
193
|
end
|
|
49
|
-
|
|
50
|
-
|
|
194
|
+
|
|
195
|
+
rows = drv.execute_query(effective_sql, params)
|
|
196
|
+
Tina4::DatabaseResult.new(rows, sql: effective_sql, db: self)
|
|
51
197
|
end
|
|
52
198
|
|
|
53
199
|
def fetch_one(sql, params = [])
|
|
200
|
+
if @cache_enabled
|
|
201
|
+
key = cache_key(sql + ":ONE", params)
|
|
202
|
+
cached = cache_get(key)
|
|
203
|
+
if cached
|
|
204
|
+
@cache_mutex.synchronize { @cache_hits += 1 }
|
|
205
|
+
return cached
|
|
206
|
+
end
|
|
207
|
+
result = fetch(sql, params, limit: 1)
|
|
208
|
+
value = result.first
|
|
209
|
+
cache_set(key, value)
|
|
210
|
+
@cache_mutex.synchronize { @cache_misses += 1 }
|
|
211
|
+
return value
|
|
212
|
+
end
|
|
213
|
+
|
|
54
214
|
result = fetch(sql, params, limit: 1)
|
|
55
215
|
result.first
|
|
56
216
|
end
|
|
57
217
|
|
|
58
218
|
def insert(table, data)
|
|
219
|
+
cache_invalidate if @cache_enabled
|
|
220
|
+
drv = current_driver
|
|
221
|
+
|
|
59
222
|
# List of hashes — batch insert
|
|
60
223
|
if data.is_a?(Array)
|
|
61
224
|
return { success: true, affected_rows: 0 } if data.empty?
|
|
62
225
|
keys = data.first.keys.map(&:to_s)
|
|
63
|
-
placeholders =
|
|
226
|
+
placeholders = drv.placeholders(keys.length)
|
|
64
227
|
sql = "INSERT INTO #{table} (#{keys.join(', ')}) VALUES (#{placeholders})"
|
|
65
228
|
params_list = data.map { |row| keys.map { |k| row[k.to_sym] || row[k] } }
|
|
66
229
|
return execute_many(sql, params_list)
|
|
67
230
|
end
|
|
68
231
|
|
|
69
232
|
columns = data.keys.map(&:to_s)
|
|
70
|
-
placeholders =
|
|
233
|
+
placeholders = drv.placeholders(columns.length)
|
|
71
234
|
sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
|
|
72
|
-
|
|
73
|
-
{ success: true, last_id:
|
|
235
|
+
drv.execute(sql, data.values)
|
|
236
|
+
{ success: true, last_id: drv.last_insert_id }
|
|
74
237
|
end
|
|
75
238
|
|
|
76
239
|
def update(table, data, filter = {})
|
|
77
|
-
|
|
78
|
-
|
|
240
|
+
cache_invalidate if @cache_enabled
|
|
241
|
+
drv = current_driver
|
|
242
|
+
|
|
243
|
+
set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
244
|
+
where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
79
245
|
sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
|
|
80
246
|
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
81
247
|
values = data.values + filter.values
|
|
82
|
-
|
|
248
|
+
drv.execute(sql, values)
|
|
83
249
|
{ success: true }
|
|
84
250
|
end
|
|
85
251
|
|
|
86
252
|
def delete(table, filter = {})
|
|
253
|
+
cache_invalidate if @cache_enabled
|
|
254
|
+
drv = current_driver
|
|
255
|
+
|
|
87
256
|
# List of hashes — delete each row
|
|
88
257
|
if filter.is_a?(Array)
|
|
89
258
|
filter.each { |row| delete(table, row) }
|
|
@@ -94,46 +263,48 @@ module Tina4
|
|
|
94
263
|
if filter.is_a?(String)
|
|
95
264
|
sql = "DELETE FROM #{table}"
|
|
96
265
|
sql += " WHERE #{filter}" unless filter.empty?
|
|
97
|
-
|
|
266
|
+
drv.execute(sql)
|
|
98
267
|
return { success: true }
|
|
99
268
|
end
|
|
100
269
|
|
|
101
270
|
# Hash filter — build WHERE from keys
|
|
102
|
-
where_parts = filter.keys.map { |k| "#{k} = #{
|
|
271
|
+
where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
103
272
|
sql = "DELETE FROM #{table}"
|
|
104
273
|
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
105
|
-
|
|
274
|
+
drv.execute(sql, filter.values)
|
|
106
275
|
{ success: true }
|
|
107
276
|
end
|
|
108
277
|
|
|
109
278
|
def execute(sql, params = [])
|
|
110
|
-
@
|
|
279
|
+
cache_invalidate if @cache_enabled
|
|
280
|
+
current_driver.execute(sql, params)
|
|
111
281
|
end
|
|
112
282
|
|
|
113
283
|
def execute_many(sql, params_list = [])
|
|
114
284
|
total_affected = 0
|
|
115
285
|
params_list.each do |params|
|
|
116
|
-
|
|
286
|
+
current_driver.execute(sql, params)
|
|
117
287
|
total_affected += 1
|
|
118
288
|
end
|
|
119
289
|
{ success: true, affected_rows: total_affected }
|
|
120
290
|
end
|
|
121
291
|
|
|
122
292
|
def transaction
|
|
123
|
-
|
|
293
|
+
drv = current_driver
|
|
294
|
+
drv.begin_transaction
|
|
124
295
|
yield self
|
|
125
|
-
|
|
296
|
+
drv.commit
|
|
126
297
|
rescue => e
|
|
127
|
-
|
|
298
|
+
drv.rollback
|
|
128
299
|
raise e
|
|
129
300
|
end
|
|
130
301
|
|
|
131
302
|
def tables
|
|
132
|
-
|
|
303
|
+
current_driver.tables
|
|
133
304
|
end
|
|
134
305
|
|
|
135
306
|
def columns(table_name)
|
|
136
|
-
|
|
307
|
+
current_driver.columns(table_name)
|
|
137
308
|
end
|
|
138
309
|
|
|
139
310
|
def table_exists?(table_name)
|
|
@@ -142,6 +313,39 @@ module Tina4
|
|
|
142
313
|
|
|
143
314
|
private
|
|
144
315
|
|
|
316
|
+
def truthy?(val)
|
|
317
|
+
%w[true 1 yes on].include?((val || "").to_s.strip.downcase)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def cache_key(sql, params)
|
|
321
|
+
Digest::SHA256.hexdigest(sql + params.to_s)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def cache_get(key)
|
|
325
|
+
@cache_mutex.synchronize do
|
|
326
|
+
entry = @query_cache[key]
|
|
327
|
+
return nil unless entry
|
|
328
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > entry[:expires_at]
|
|
329
|
+
@query_cache.delete(key)
|
|
330
|
+
return nil
|
|
331
|
+
end
|
|
332
|
+
entry[:value]
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def cache_set(key, value)
|
|
337
|
+
@cache_mutex.synchronize do
|
|
338
|
+
@query_cache[key] = {
|
|
339
|
+
expires_at: Process.clock_gettime(Process::CLOCK_MONOTONIC) + @cache_ttl,
|
|
340
|
+
value: value
|
|
341
|
+
}
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def cache_invalidate
|
|
346
|
+
@cache_mutex.synchronize { @query_cache.clear }
|
|
347
|
+
end
|
|
348
|
+
|
|
145
349
|
def detect_driver(conn)
|
|
146
350
|
case conn.to_s.downcase
|
|
147
351
|
when /\.db$/, /\.sqlite/, /sqlite/
|