tina4ruby 3.0.0 → 3.2.1
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 +105 -16
- data/lib/tina4/cli.rb +154 -27
- data/lib/tina4/database.rb +101 -0
- data/lib/tina4/dev_mailbox.rb +1 -1
- data/lib/tina4/frond.rb +166 -5
- 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/messenger.rb +111 -33
- data/lib/tina4/orm.rb +134 -12
- data/lib/tina4/queue.rb +125 -5
- data/lib/tina4/rack_app.rb +18 -5
- data/lib/tina4/response_cache.rb +446 -29
- data/lib/tina4/templates/errors/404.twig +2 -2
- data/lib/tina4/templates/errors/500.twig +1 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +63 -2
- metadata +29 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c9e0a35ede3d5672d8d4b1c4197129db19443f60638e0596b1e94f367f9a40aa
|
|
4
|
+
data.tar.gz: 37d5d5d98b0511ac523eeec07d8b2ada21d5a47553bab176ccbce9a01a600389
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9f9f9b1240111c24ba20e461439c55c043792206f5b2150811f2c8d97b1e0e7e937d644777d4c2a06c59888b994f01aa8d54ebcffdad5dffcbdca159f3868fab
|
|
7
|
+
data.tar.gz: 581ac0b6d4b0493f0d787d53846d9c1811e9dab6515797ad693ecceec8e5b7401e92d08319b434c3729df0321b3399b8b73019e2b701d39664fa2f69c8baa20a
|
data/README.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
</p>
|
|
19
19
|
|
|
20
20
|
<p align="center">
|
|
21
|
-
<img src="https://img.shields.io/badge/tests-
|
|
21
|
+
<img src="https://img.shields.io/badge/tests-1577%20passing-brightgreen" alt="Tests">
|
|
22
22
|
<img src="https://img.shields.io/badge/carbonah-A%2B%20rated-00cc44" alt="Carbonah A+">
|
|
23
23
|
<img src="https://img.shields.io/badge/zero--dep-core-blue" alt="Zero Dependencies">
|
|
24
24
|
<img src="https://img.shields.io/badge/ruby-3.1%2B-blue" alt="Ruby 3.1+">
|
|
@@ -27,17 +27,53 @@
|
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## Quick Start
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
# Install the Tina4 CLI
|
|
34
|
+
cargo install tina4 # or download binary from https://github.com/tina4stack/tina4/releases
|
|
35
|
+
|
|
36
|
+
# Create a project
|
|
37
|
+
tina4 init ruby ./my-app
|
|
38
|
+
|
|
39
|
+
# Run it
|
|
40
|
+
cd my-app && tina4 serve
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Open http://localhost:7147 — your app is running.
|
|
44
|
+
|
|
45
|
+
<details>
|
|
46
|
+
<summary><strong>Without the Tina4 CLI</strong></summary>
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# 1. Create project
|
|
50
|
+
mkdir my-app && cd my-app
|
|
51
|
+
echo 'source "https://rubygems.org"' > Gemfile
|
|
52
|
+
echo 'gem "tina4-ruby", "~> 3.0"' >> Gemfile
|
|
53
|
+
bundle install
|
|
54
|
+
|
|
55
|
+
# 2. Create entry point
|
|
56
|
+
cat > app.rb << 'EOF'
|
|
57
|
+
require "tina4"
|
|
58
|
+
Tina4.initialize!(__dir__)
|
|
59
|
+
app = Tina4::RackApp.new
|
|
60
|
+
Tina4::WebServer.new(app, host: "0.0.0.0", port: 7147).start
|
|
61
|
+
EOF
|
|
62
|
+
|
|
63
|
+
# 3. Create .env
|
|
64
|
+
echo 'TINA4_DEBUG=true' > .env
|
|
65
|
+
echo 'TINA4_LOG_LEVEL=ALL' >> .env
|
|
66
|
+
|
|
67
|
+
# 4. Create route directory
|
|
68
|
+
mkdir -p src/routes
|
|
69
|
+
|
|
70
|
+
# 5. Run
|
|
71
|
+
bundle exec ruby app.rb
|
|
38
72
|
```
|
|
39
73
|
|
|
40
|
-
|
|
74
|
+
Open http://localhost:7147
|
|
75
|
+
|
|
76
|
+
</details>
|
|
41
77
|
|
|
42
78
|
---
|
|
43
79
|
|
|
@@ -50,7 +86,7 @@ Every feature is built from scratch -- no gem install, no node_modules, no third
|
|
|
50
86
|
| **HTTP** | Rack 3 server, block routing, path params (`{id:int}`, `{p:path}`), middleware pipeline, CORS, rate limiting, graceful shutdown |
|
|
51
87
|
| **Templates** | Frond engine (Twig-compatible), inheritance, partials, 35+ filters, macros, fragment caching, sandboxing |
|
|
52
88
|
| **ORM** | Active Record, typed fields with validation, soft delete, relationships (`has_one`/`has_many`/`belongs_to`), scopes, result caching, multi-database |
|
|
53
|
-
| **Database** | SQLite, PostgreSQL, MySQL, MSSQL, Firebird -- unified adapter interface |
|
|
89
|
+
| **Database** | SQLite, PostgreSQL, MySQL, MSSQL, Firebird -- unified adapter interface, query caching (TINA4_DB_CACHE=true for 4x speedup) |
|
|
54
90
|
| **Auth** | Zero-dep JWT (RS256), sessions (file, Redis, MongoDB), password hashing, form tokens |
|
|
55
91
|
| **API** | Swagger/OpenAPI auto-generation, GraphQL with ORM auto-schema and GraphiQL IDE |
|
|
56
92
|
| **Background** | DB-backed queue with priority, delayed jobs, retry, batch processing, multi-queue |
|
|
@@ -58,9 +94,9 @@ Every feature is built from scratch -- no gem install, no node_modules, no third
|
|
|
58
94
|
| **Frontend** | tina4-css (~24 KB), frond.js helper, SCSS compiler, live reload, CSS hot-reload |
|
|
59
95
|
| **DX** | Dev admin dashboard, error overlay, request inspector, AI tool integration, Carbonah green benchmarks |
|
|
60
96
|
| **Data** | Migrations with rollback, 50+ fake data generators, ORM and table seeders |
|
|
61
|
-
| **Other** | REST client, localization (6 languages),
|
|
97
|
+
| **Other** | REST client, localization (6 languages), cache (memory/Redis/file), event system, inline testing, messenger (.env driven), configurable error pages |
|
|
62
98
|
|
|
63
|
-
**
|
|
99
|
+
**1,577 tests across 38 built-in features. Zero dependencies. All Carbonah benchmarks rated A+.**
|
|
64
100
|
|
|
65
101
|
For full documentation visit **[tina4.com](https://tina4.com)**.
|
|
66
102
|
|
|
@@ -345,9 +381,9 @@ end
|
|
|
345
381
|
### JWT Authentication
|
|
346
382
|
|
|
347
383
|
```ruby
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
payload =
|
|
384
|
+
token = Tina4::Auth.create_token({ user_id: 42 })
|
|
385
|
+
result = Tina4::Auth.validate_token(token)
|
|
386
|
+
payload = Tina4::Auth.get_payload(token)
|
|
351
387
|
```
|
|
352
388
|
|
|
353
389
|
POST/PUT/PATCH/DELETE routes require `Authorization: Bearer <token>` by default. Use `auth: false` to make public, `secure_get` to protect GET routes.
|
|
@@ -486,7 +522,7 @@ cache.tag("users").flush
|
|
|
486
522
|
|
|
487
523
|
## Dev Mode
|
|
488
524
|
|
|
489
|
-
Set `
|
|
525
|
+
Set `TINA4_DEBUG=true` in `.env` to enable:
|
|
490
526
|
|
|
491
527
|
- **Live reload** -- browser auto-refreshes on code changes
|
|
492
528
|
- **CSS hot-reload** -- SCSS changes apply without page refresh
|
|
@@ -500,9 +536,14 @@ Set `TINA4_DEBUG_LEVEL=DEBUG` in `.env` to enable:
|
|
|
500
536
|
```bash
|
|
501
537
|
tina4ruby init [dir] # Scaffold a new project
|
|
502
538
|
tina4ruby serve [port] # Start dev server (default: 7147)
|
|
539
|
+
tina4ruby serve --production # Auto-install and use Puma production server
|
|
503
540
|
tina4ruby migrate # Run pending migrations
|
|
504
541
|
tina4ruby migrate --create <desc># Create a migration file
|
|
505
542
|
tina4ruby migrate --rollback # Rollback last batch
|
|
543
|
+
tina4ruby generate model <name> # Generate ORM model scaffold
|
|
544
|
+
tina4ruby generate route <name> # Generate route scaffold
|
|
545
|
+
tina4ruby generate migration <d> # Generate migration file
|
|
546
|
+
tina4ruby generate middleware <n># Generate middleware scaffold
|
|
506
547
|
tina4ruby seed # Run seeders from src/seeds/
|
|
507
548
|
tina4ruby routes # List all registered routes
|
|
508
549
|
tina4ruby test # Run test suite
|
|
@@ -510,6 +551,53 @@ tina4ruby build # Build distributable gem
|
|
|
510
551
|
tina4ruby ai [--all] # Detect AI tools and install context
|
|
511
552
|
```
|
|
512
553
|
|
|
554
|
+
### Production Server Auto-Detection
|
|
555
|
+
|
|
556
|
+
`tina4 serve` automatically detects and uses the best available production server:
|
|
557
|
+
|
|
558
|
+
- **Ruby**: Puma (if installed), otherwise WEBrick -- Puma gives 2.8x improvement
|
|
559
|
+
- Use `tina4ruby serve --production` to auto-install Puma
|
|
560
|
+
|
|
561
|
+
### Scaffolding with `tina4 generate`
|
|
562
|
+
|
|
563
|
+
Quickly scaffold new components:
|
|
564
|
+
|
|
565
|
+
```bash
|
|
566
|
+
tina4ruby generate model User # Creates src/orm/user.rb with field stubs
|
|
567
|
+
tina4ruby generate route users # Creates src/routes/users.rb with CRUD stubs
|
|
568
|
+
tina4ruby generate migration "add age" # Creates migration SQL file
|
|
569
|
+
tina4ruby generate middleware AuthLog # Creates middleware class
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
### ORM Relationships & Eager Loading
|
|
573
|
+
|
|
574
|
+
```ruby
|
|
575
|
+
# Relationships
|
|
576
|
+
orders = user.has_many("Order", "user_id")
|
|
577
|
+
profile = user.has_one("Profile", "user_id")
|
|
578
|
+
customer = order.belongs_to("Customer", "customer_id")
|
|
579
|
+
|
|
580
|
+
# Eager loading with include:
|
|
581
|
+
users = User.all(include: ["orders", "profile"])
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
### DB Query Caching
|
|
585
|
+
|
|
586
|
+
Enable query caching for up to 4x speedup on read-heavy workloads:
|
|
587
|
+
|
|
588
|
+
```bash
|
|
589
|
+
# .env
|
|
590
|
+
TINA4_DB_CACHE=true
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
### Frond Pre-Compilation
|
|
594
|
+
|
|
595
|
+
Templates are pre-compiled for 2.8x faster rendering.
|
|
596
|
+
|
|
597
|
+
### Gallery
|
|
598
|
+
|
|
599
|
+
7 interactive examples with **Try It** deploy.
|
|
600
|
+
|
|
513
601
|
## Environment
|
|
514
602
|
|
|
515
603
|
```bash
|
|
@@ -517,7 +605,8 @@ SECRET=your-jwt-secret
|
|
|
517
605
|
DATABASE_URL=sqlite://data/app.db
|
|
518
606
|
DATABASE_USERNAME=
|
|
519
607
|
DATABASE_PASSWORD=
|
|
520
|
-
|
|
608
|
+
TINA4_DEBUG=true # Enable dev toolbar, error overlay
|
|
609
|
+
TINA4_LOG_LEVEL=ALL # ALL, DEBUG, INFO, WARNING, ERROR
|
|
521
610
|
TINA4_LANGUAGE=en # en, fr, af, zh, ja, es
|
|
522
611
|
TINA4_SESSION_HANDLER=SessionFileHandler
|
|
523
612
|
SWAGGER_TITLE=My API
|
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 seed seed:create test version routes console generate ai help].freeze
|
|
9
9
|
|
|
10
10
|
def self.start(argv)
|
|
11
11
|
new.run(argv)
|
|
@@ -23,6 +23,7 @@ module Tina4
|
|
|
23
23
|
when "version" then cmd_version
|
|
24
24
|
when "routes" then cmd_routes
|
|
25
25
|
when "console" then cmd_console
|
|
26
|
+
when "generate" then cmd_generate(argv)
|
|
26
27
|
when "ai" then cmd_ai(argv)
|
|
27
28
|
when "help", "-h", "--help" then cmd_help
|
|
28
29
|
else
|
|
@@ -97,37 +98,45 @@ module Tina4
|
|
|
97
98
|
|
|
98
99
|
app = Tina4::RackApp.new(root_dir: root_dir)
|
|
99
100
|
|
|
101
|
+
is_debug = Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
|
|
102
|
+
|
|
100
103
|
# 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
|
-
|
|
104
|
+
# In debug mode, always use WEBrick for dev toolbar/reload support
|
|
105
|
+
if !is_debug
|
|
106
|
+
begin
|
|
107
|
+
require "puma"
|
|
108
|
+
require "puma/configuration"
|
|
109
|
+
require "puma/launcher"
|
|
110
|
+
|
|
111
|
+
puma_host = options[:host]
|
|
112
|
+
puma_port = options[:port]
|
|
113
|
+
|
|
114
|
+
config = Puma::Configuration.new do |user_config|
|
|
115
|
+
user_config.bind "tcp://#{puma_host}:#{puma_port}"
|
|
116
|
+
user_config.app app
|
|
117
|
+
user_config.threads 0, 16
|
|
118
|
+
user_config.workers 0
|
|
119
|
+
user_config.environment "production"
|
|
120
|
+
user_config.log_requests false
|
|
121
|
+
user_config.quiet
|
|
122
|
+
end
|
|
118
123
|
|
|
119
|
-
|
|
124
|
+
Tina4::Log.info("Production server: puma")
|
|
120
125
|
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
# Setup graceful shutdown (Puma manages its own signals, but we handle DB cleanup)
|
|
127
|
+
Tina4::Shutdown.setup
|
|
123
128
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
launcher = Puma::Launcher.new(config)
|
|
130
|
+
launcher.run
|
|
131
|
+
return
|
|
132
|
+
rescue LoadError
|
|
133
|
+
# Puma not installed, fall through to WEBrick
|
|
134
|
+
end
|
|
130
135
|
end
|
|
136
|
+
|
|
137
|
+
Tina4::Log.info("Development server: WEBrick")
|
|
138
|
+
server = Tina4::WebServer.new(app, host: options[:host], port: options[:port])
|
|
139
|
+
server.start
|
|
131
140
|
end
|
|
132
141
|
|
|
133
142
|
# ── migrate ───────────────────────────────────────────────────────────
|
|
@@ -310,6 +319,123 @@ module Tina4
|
|
|
310
319
|
|
|
311
320
|
# ── help ──────────────────────────────────────────────────────────────
|
|
312
321
|
|
|
322
|
+
# ── generate ────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
def cmd_generate(argv)
|
|
325
|
+
what = argv.shift
|
|
326
|
+
name = argv.shift
|
|
327
|
+
unless what && name
|
|
328
|
+
puts "Usage: tina4ruby generate <what> <name>"
|
|
329
|
+
puts " Generators: model, route, migration, middleware"
|
|
330
|
+
exit 1
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
case what
|
|
334
|
+
when "model" then generate_model(name)
|
|
335
|
+
when "route" then generate_route(name)
|
|
336
|
+
when "migration" then generate_migration(name)
|
|
337
|
+
when "middleware" then generate_middleware(name)
|
|
338
|
+
else
|
|
339
|
+
puts "Unknown generator: #{what}"
|
|
340
|
+
puts " Available: model, route, migration, middleware"
|
|
341
|
+
exit 1
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def generate_model(name)
|
|
346
|
+
dir = "src/orm"
|
|
347
|
+
FileUtils.mkdir_p(dir)
|
|
348
|
+
snake = name.gsub(/([A-Z])/) { |m| ($~.begin(0) > 0 ? "_" : "") + m.downcase }
|
|
349
|
+
path = File.join(dir, "#{snake}.rb")
|
|
350
|
+
abort " File already exists: #{path}" if File.exist?(path)
|
|
351
|
+
File.write(path, <<~RUBY)
|
|
352
|
+
class #{name} < Tina4::ORM
|
|
353
|
+
integer_field :id, primary_key: true, auto_increment: true
|
|
354
|
+
string_field :name
|
|
355
|
+
string_field :email
|
|
356
|
+
end
|
|
357
|
+
RUBY
|
|
358
|
+
puts " Created #{path}"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def generate_route(name)
|
|
362
|
+
route_path = name.sub(%r{^/}, "")
|
|
363
|
+
dir = "src/routes/#{route_path}"
|
|
364
|
+
FileUtils.mkdir_p(dir)
|
|
365
|
+
path = dir.chomp("/") + ".rb"
|
|
366
|
+
abort " File already exists: #{path}" if File.exist?(path)
|
|
367
|
+
File.write(path, <<~RUBY)
|
|
368
|
+
Tina4.get "/#{route_path}" do |request, response|
|
|
369
|
+
response.json(data: [])
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
Tina4.get "/#{route_path}/:id" do |request, response|
|
|
373
|
+
response.json(data: {})
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
Tina4.post "/#{route_path}" do |request, response|
|
|
377
|
+
response.json({ message: "created" }, 201)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
Tina4.put "/#{route_path}/:id" do |request, response|
|
|
381
|
+
response.json(message: "updated")
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
Tina4.delete "/#{route_path}/:id" do |request, response|
|
|
385
|
+
response.json(message: "deleted")
|
|
386
|
+
end
|
|
387
|
+
RUBY
|
|
388
|
+
puts " Created #{path}"
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def generate_migration(name)
|
|
392
|
+
dir = "migrations"
|
|
393
|
+
FileUtils.mkdir_p(dir)
|
|
394
|
+
timestamp = Time.now.strftime("%Y%m%d%H%M%S")
|
|
395
|
+
table = name.sub(/^create_/, "")
|
|
396
|
+
table = if table.end_with?("s")
|
|
397
|
+
table
|
|
398
|
+
elsif table.end_with?("y")
|
|
399
|
+
table[0..-2] + "ies"
|
|
400
|
+
else
|
|
401
|
+
table + "s"
|
|
402
|
+
end
|
|
403
|
+
filename = "#{timestamp}_#{name}.sql"
|
|
404
|
+
path = File.join(dir, filename)
|
|
405
|
+
now = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
|
406
|
+
File.write(path, <<~SQL)
|
|
407
|
+
-- Migration: #{name}
|
|
408
|
+
-- Created: #{now}
|
|
409
|
+
|
|
410
|
+
CREATE TABLE #{table} (
|
|
411
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
412
|
+
name TEXT NOT NULL,
|
|
413
|
+
email TEXT NOT NULL,
|
|
414
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
415
|
+
);
|
|
416
|
+
SQL
|
|
417
|
+
puts " Created #{path}"
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def generate_middleware(name)
|
|
421
|
+
dir = "src/middleware"
|
|
422
|
+
FileUtils.mkdir_p(dir)
|
|
423
|
+
snake = name.gsub(/([A-Z])/) { |m| ($~.begin(0) > 0 ? "_" : "") + m.downcase }
|
|
424
|
+
path = File.join(dir, "#{snake}.rb")
|
|
425
|
+
abort " File already exists: #{path}" if File.exist?(path)
|
|
426
|
+
File.write(path, <<~RUBY)
|
|
427
|
+
class #{name} < Tina4::Middleware
|
|
428
|
+
def process(request, response)
|
|
429
|
+
auth = request.headers["Authorization"]
|
|
430
|
+
return response.json({ error: "Unauthorized" }, 401) unless auth
|
|
431
|
+
|
|
432
|
+
nil
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
RUBY
|
|
436
|
+
puts " Created #{path}"
|
|
437
|
+
end
|
|
438
|
+
|
|
313
439
|
def cmd_help
|
|
314
440
|
puts <<~HELP
|
|
315
441
|
Tina4 Ruby CLI
|
|
@@ -326,6 +452,7 @@ module Tina4
|
|
|
326
452
|
version Show Tina4 version
|
|
327
453
|
routes List all registered routes
|
|
328
454
|
console Start an interactive console
|
|
455
|
+
generate <what> <name> Generate scaffolding (model, route, migration, middleware)
|
|
329
456
|
ai Detect AI tools and install context files
|
|
330
457
|
help Show this help message
|
|
331
458
|
|
data/lib/tina4/database.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "json"
|
|
3
3
|
require "uri"
|
|
4
|
+
require "digest"
|
|
4
5
|
|
|
5
6
|
module Tina4
|
|
6
7
|
class Database
|
|
@@ -24,6 +25,15 @@ module Tina4
|
|
|
24
25
|
@driver_name = driver_name || detect_driver(@connection_string)
|
|
25
26
|
@driver = create_driver
|
|
26
27
|
@connected = false
|
|
28
|
+
|
|
29
|
+
# Query cache — off by default, opt-in via TINA4_DB_CACHE=true
|
|
30
|
+
@cache_enabled = truthy?(ENV["TINA4_DB_CACHE"])
|
|
31
|
+
@cache_ttl = (ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
|
|
32
|
+
@query_cache = {} # key => { expires_at:, value: }
|
|
33
|
+
@cache_hits = 0
|
|
34
|
+
@cache_misses = 0
|
|
35
|
+
@cache_mutex = Mutex.new
|
|
36
|
+
|
|
27
37
|
connect
|
|
28
38
|
end
|
|
29
39
|
|
|
@@ -41,21 +51,74 @@ module Tina4
|
|
|
41
51
|
@connected = false
|
|
42
52
|
end
|
|
43
53
|
|
|
54
|
+
# ── Query Cache ──────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def cache_stats
|
|
57
|
+
@cache_mutex.synchronize do
|
|
58
|
+
{
|
|
59
|
+
enabled: @cache_enabled,
|
|
60
|
+
hits: @cache_hits,
|
|
61
|
+
misses: @cache_misses,
|
|
62
|
+
size: @query_cache.size,
|
|
63
|
+
ttl: @cache_ttl
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def cache_clear
|
|
69
|
+
@cache_mutex.synchronize do
|
|
70
|
+
@query_cache.clear
|
|
71
|
+
@cache_hits = 0
|
|
72
|
+
@cache_misses = 0
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
44
76
|
def fetch(sql, params = [], limit: nil, skip: nil)
|
|
45
77
|
effective_sql = sql
|
|
46
78
|
if limit
|
|
47
79
|
effective_sql = @driver.apply_limit(effective_sql, limit, skip || 0)
|
|
48
80
|
end
|
|
81
|
+
|
|
82
|
+
if @cache_enabled
|
|
83
|
+
key = cache_key(effective_sql, params)
|
|
84
|
+
cached = cache_get(key)
|
|
85
|
+
if cached
|
|
86
|
+
@cache_mutex.synchronize { @cache_hits += 1 }
|
|
87
|
+
return cached
|
|
88
|
+
end
|
|
89
|
+
result = @driver.execute_query(effective_sql, params)
|
|
90
|
+
result = Tina4::DatabaseResult.new(result, sql: effective_sql)
|
|
91
|
+
cache_set(key, result)
|
|
92
|
+
@cache_mutex.synchronize { @cache_misses += 1 }
|
|
93
|
+
return result
|
|
94
|
+
end
|
|
95
|
+
|
|
49
96
|
rows = @driver.execute_query(effective_sql, params)
|
|
50
97
|
Tina4::DatabaseResult.new(rows, sql: effective_sql)
|
|
51
98
|
end
|
|
52
99
|
|
|
53
100
|
def fetch_one(sql, params = [])
|
|
101
|
+
if @cache_enabled
|
|
102
|
+
key = cache_key(sql + ":ONE", params)
|
|
103
|
+
cached = cache_get(key)
|
|
104
|
+
if cached
|
|
105
|
+
@cache_mutex.synchronize { @cache_hits += 1 }
|
|
106
|
+
return cached
|
|
107
|
+
end
|
|
108
|
+
result = fetch(sql, params, limit: 1)
|
|
109
|
+
value = result.first
|
|
110
|
+
cache_set(key, value)
|
|
111
|
+
@cache_mutex.synchronize { @cache_misses += 1 }
|
|
112
|
+
return value
|
|
113
|
+
end
|
|
114
|
+
|
|
54
115
|
result = fetch(sql, params, limit: 1)
|
|
55
116
|
result.first
|
|
56
117
|
end
|
|
57
118
|
|
|
58
119
|
def insert(table, data)
|
|
120
|
+
cache_invalidate if @cache_enabled
|
|
121
|
+
|
|
59
122
|
# List of hashes — batch insert
|
|
60
123
|
if data.is_a?(Array)
|
|
61
124
|
return { success: true, affected_rows: 0 } if data.empty?
|
|
@@ -74,6 +137,8 @@ module Tina4
|
|
|
74
137
|
end
|
|
75
138
|
|
|
76
139
|
def update(table, data, filter = {})
|
|
140
|
+
cache_invalidate if @cache_enabled
|
|
141
|
+
|
|
77
142
|
set_parts = data.keys.map { |k| "#{k} = #{@driver.placeholder}" }
|
|
78
143
|
where_parts = filter.keys.map { |k| "#{k} = #{@driver.placeholder}" }
|
|
79
144
|
sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
|
|
@@ -84,6 +149,8 @@ module Tina4
|
|
|
84
149
|
end
|
|
85
150
|
|
|
86
151
|
def delete(table, filter = {})
|
|
152
|
+
cache_invalidate if @cache_enabled
|
|
153
|
+
|
|
87
154
|
# List of hashes — delete each row
|
|
88
155
|
if filter.is_a?(Array)
|
|
89
156
|
filter.each { |row| delete(table, row) }
|
|
@@ -107,6 +174,7 @@ module Tina4
|
|
|
107
174
|
end
|
|
108
175
|
|
|
109
176
|
def execute(sql, params = [])
|
|
177
|
+
cache_invalidate if @cache_enabled
|
|
110
178
|
@driver.execute(sql, params)
|
|
111
179
|
end
|
|
112
180
|
|
|
@@ -142,6 +210,39 @@ module Tina4
|
|
|
142
210
|
|
|
143
211
|
private
|
|
144
212
|
|
|
213
|
+
def truthy?(val)
|
|
214
|
+
%w[true 1 yes on].include?((val || "").to_s.strip.downcase)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def cache_key(sql, params)
|
|
218
|
+
Digest::SHA256.hexdigest(sql + params.to_s)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def cache_get(key)
|
|
222
|
+
@cache_mutex.synchronize do
|
|
223
|
+
entry = @query_cache[key]
|
|
224
|
+
return nil unless entry
|
|
225
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > entry[:expires_at]
|
|
226
|
+
@query_cache.delete(key)
|
|
227
|
+
return nil
|
|
228
|
+
end
|
|
229
|
+
entry[:value]
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def cache_set(key, value)
|
|
234
|
+
@cache_mutex.synchronize do
|
|
235
|
+
@query_cache[key] = {
|
|
236
|
+
expires_at: Process.clock_gettime(Process::CLOCK_MONOTONIC) + @cache_ttl,
|
|
237
|
+
value: value
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def cache_invalidate
|
|
243
|
+
@cache_mutex.synchronize { @query_cache.clear }
|
|
244
|
+
end
|
|
245
|
+
|
|
145
246
|
def detect_driver(conn)
|
|
146
247
|
case conn.to_s.downcase
|
|
147
248
|
when /\.db$/, /\.sqlite/, /sqlite/
|
data/lib/tina4/dev_mailbox.rb
CHANGED