tina4ruby 3.10.90 → 3.10.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/tina4/api.rb +50 -13
- data/lib/tina4/auth.rb +11 -3
- data/lib/tina4/auto_crud.rb +5 -5
- data/lib/tina4/cache.rb +154 -0
- data/lib/tina4/cli.rb +1 -1
- data/lib/tina4/container.rb +6 -6
- data/lib/tina4/crud.rb +3 -3
- data/lib/tina4/database.rb +90 -4
- data/lib/tina4/drivers/sqlite_driver.rb +4 -0
- data/lib/tina4/frond.rb +8 -0
- data/lib/tina4/graphql.rb +27 -24
- data/lib/tina4/health.rb +1 -1
- data/lib/tina4/job.rb +8 -4
- data/lib/tina4/localization.rb +21 -0
- data/lib/tina4/middleware.rb +18 -6
- data/lib/tina4/migration.rb +76 -25
- data/lib/tina4/orm.rb +23 -4
- data/lib/tina4/queue.rb +96 -21
- data/lib/tina4/queue_backends/lite_backend.rb +42 -1
- data/lib/tina4/rack_app.rb +3 -3
- data/lib/tina4/router.rb +34 -15
- data/lib/tina4/seeder.rb +33 -1
- data/lib/tina4/session.rb +59 -5
- data/lib/tina4/sql_translation.rb +1 -138
- data/lib/tina4/test_client.rb +1 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +65 -39
- data/lib/tina4.rb +15 -14
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a737d8b571f74eceea00422b681722ba711019f59c1fae14a8cc618bce5aa51
|
|
4
|
+
data.tar.gz: 6834b22997597eeb49064e17f56a6fd00280f9dc57c699a413402ff4fcbcc1c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d85adcf4bfc4ca50956912e2fe926fc9cbe2833c0cb2a134d376ad63d13066d6c533e5418ff9aad40065a5c2a6313145d2a3da574f0a494fb86f906ca85545f5
|
|
7
|
+
data.tar.gz: 93a399a93f6c98ce34e0c521c33221cf5ac6d69238c0103099e7ecaa0f9c3c9c1d6ae2db48f0a2e12585e7d7700545a045c1e2122152657bdb753f518c4efcb5
|
data/lib/tina4/api.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
require "net/http"
|
|
3
3
|
require "uri"
|
|
4
4
|
require "json"
|
|
5
|
+
require "base64"
|
|
5
6
|
|
|
6
7
|
module Tina4
|
|
7
8
|
class API
|
|
@@ -16,41 +17,51 @@ module Tina4
|
|
|
16
17
|
@timeout = timeout
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
def get(path, params: {}
|
|
20
|
+
def get(path, params: {})
|
|
20
21
|
uri = build_uri(path, params)
|
|
21
22
|
request = Net::HTTP::Get.new(uri)
|
|
22
|
-
apply_headers(request,
|
|
23
|
+
apply_headers(request, {})
|
|
23
24
|
execute(uri, request)
|
|
24
25
|
end
|
|
25
26
|
|
|
26
|
-
def post(path, body: nil,
|
|
27
|
+
def post(path, body: nil, content_type: "application/json")
|
|
27
28
|
uri = build_uri(path)
|
|
28
29
|
request = Net::HTTP::Post.new(uri)
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
if body
|
|
31
|
+
request.body = body.is_a?(String) ? body : JSON.generate(body)
|
|
32
|
+
request["Content-Type"] = content_type
|
|
33
|
+
end
|
|
34
|
+
apply_headers(request, {})
|
|
31
35
|
execute(uri, request)
|
|
32
36
|
end
|
|
33
37
|
|
|
34
|
-
def put(path, body: nil,
|
|
38
|
+
def put(path, body: nil, content_type: "application/json")
|
|
35
39
|
uri = build_uri(path)
|
|
36
40
|
request = Net::HTTP::Put.new(uri)
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
if body
|
|
42
|
+
request.body = body.is_a?(String) ? body : JSON.generate(body)
|
|
43
|
+
request["Content-Type"] = content_type
|
|
44
|
+
end
|
|
45
|
+
apply_headers(request, {})
|
|
39
46
|
execute(uri, request)
|
|
40
47
|
end
|
|
41
48
|
|
|
42
|
-
def patch(path, body: nil,
|
|
49
|
+
def patch(path, body: nil, content_type: "application/json")
|
|
43
50
|
uri = build_uri(path)
|
|
44
51
|
request = Net::HTTP::Patch.new(uri)
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
if body
|
|
53
|
+
request.body = body.is_a?(String) ? body : JSON.generate(body)
|
|
54
|
+
request["Content-Type"] = content_type
|
|
55
|
+
end
|
|
56
|
+
apply_headers(request, {})
|
|
47
57
|
execute(uri, request)
|
|
48
58
|
end
|
|
49
59
|
|
|
50
|
-
def delete(path,
|
|
60
|
+
def delete(path, body: nil)
|
|
51
61
|
uri = build_uri(path)
|
|
52
62
|
request = Net::HTTP::Delete.new(uri)
|
|
53
|
-
|
|
63
|
+
request.body = body.is_a?(String) ? body : JSON.generate(body) if body
|
|
64
|
+
apply_headers(request, {})
|
|
54
65
|
execute(uri, request)
|
|
55
66
|
end
|
|
56
67
|
|
|
@@ -67,6 +78,32 @@ module Tina4
|
|
|
67
78
|
execute(uri, request)
|
|
68
79
|
end
|
|
69
80
|
|
|
81
|
+
def set_basic_auth(username, password)
|
|
82
|
+
@headers["Authorization"] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def set_bearer_token(token)
|
|
87
|
+
@headers["Authorization"] = "Bearer #{token}"
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def add_headers(headers)
|
|
92
|
+
@headers.merge!(headers)
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def send_request(method = "GET", path = "", body: nil, content_type: "application/json")
|
|
97
|
+
case method.upcase
|
|
98
|
+
when "GET" then get(path)
|
|
99
|
+
when "POST" then post(path, body: body, content_type: content_type)
|
|
100
|
+
when "PUT" then put(path, body: body, content_type: content_type)
|
|
101
|
+
when "PATCH" then patch(path, body: body, content_type: content_type)
|
|
102
|
+
when "DELETE" then delete(path, body: body)
|
|
103
|
+
else get(path)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
70
107
|
private
|
|
71
108
|
|
|
72
109
|
def build_uri(path, params = {})
|
data/lib/tina4/auth.rb
CHANGED
|
@@ -89,7 +89,7 @@ module Tina4
|
|
|
89
89
|
|
|
90
90
|
# ── Token API (auto-selects HS256 or RS256) ─────────────────
|
|
91
91
|
|
|
92
|
-
def get_token(payload, expires_in: 60)
|
|
92
|
+
def get_token(payload, expires_in: 60, secret: nil)
|
|
93
93
|
now = Time.now.to_i
|
|
94
94
|
claims = payload.merge(
|
|
95
95
|
"iat" => now,
|
|
@@ -97,7 +97,9 @@ module Tina4
|
|
|
97
97
|
"nbf" => now
|
|
98
98
|
)
|
|
99
99
|
|
|
100
|
-
if
|
|
100
|
+
if secret
|
|
101
|
+
hmac_encode(claims, secret)
|
|
102
|
+
elsif use_hmac?
|
|
101
103
|
hmac_encode(claims, hmac_secret)
|
|
102
104
|
else
|
|
103
105
|
ensure_keys
|
|
@@ -182,7 +184,7 @@ module Tina4
|
|
|
182
184
|
get_token(payload, expires_in: expires_in)
|
|
183
185
|
end
|
|
184
186
|
|
|
185
|
-
def authenticate_request(headers)
|
|
187
|
+
def authenticate_request(headers, secret: nil, algorithm: "HS256")
|
|
186
188
|
auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || ""
|
|
187
189
|
return nil unless auth_header =~ /\ABearer\s+(.+)\z/i
|
|
188
190
|
|
|
@@ -194,6 +196,12 @@ module Tina4
|
|
|
194
196
|
return { "api_key" => true }
|
|
195
197
|
end
|
|
196
198
|
|
|
199
|
+
# If a custom secret is provided, validate against it directly
|
|
200
|
+
if secret
|
|
201
|
+
payload = hmac_decode(token, secret)
|
|
202
|
+
return payload ? payload : nil
|
|
203
|
+
end
|
|
204
|
+
|
|
197
205
|
valid_token(token) ? get_payload(token) : nil
|
|
198
206
|
end
|
|
199
207
|
|
data/lib/tina4/auto_crud.rb
CHANGED
|
@@ -53,7 +53,7 @@ module Tina4
|
|
|
53
53
|
example_body = build_example(model_class)
|
|
54
54
|
|
|
55
55
|
# GET /api/{table} -- list all with pagination, filtering, sorting
|
|
56
|
-
Tina4::Router.
|
|
56
|
+
Tina4::Router.add("GET", "#{prefix}/#{table}", proc { |req, res|
|
|
57
57
|
begin
|
|
58
58
|
per_page = (req.query["per_page"] || req.query["limit"] || 10).to_i
|
|
59
59
|
page = (req.query["page"] || 1).to_i
|
|
@@ -94,7 +94,7 @@ module Tina4
|
|
|
94
94
|
}, swagger_meta: { summary: "List all #{pretty_name}", tags: [table.to_s] })
|
|
95
95
|
|
|
96
96
|
# GET /api/{table}/{id} -- get single record
|
|
97
|
-
Tina4::Router.
|
|
97
|
+
Tina4::Router.add("GET", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
98
98
|
begin
|
|
99
99
|
id = req.params["id"]
|
|
100
100
|
record = model_class.find_by_id(id.to_i)
|
|
@@ -109,7 +109,7 @@ module Tina4
|
|
|
109
109
|
}, swagger_meta: { summary: "Get #{pretty_name} by ID", tags: [table.to_s] })
|
|
110
110
|
|
|
111
111
|
# POST /api/{table} -- create record
|
|
112
|
-
Tina4::Router.
|
|
112
|
+
Tina4::Router.add("POST", "#{prefix}/#{table}", proc { |req, res|
|
|
113
113
|
begin
|
|
114
114
|
attributes = req.body_parsed
|
|
115
115
|
record = model_class.create(attributes)
|
|
@@ -137,7 +137,7 @@ module Tina4
|
|
|
137
137
|
})
|
|
138
138
|
|
|
139
139
|
# PUT /api/{table}/{id} -- update record
|
|
140
|
-
Tina4::Router.
|
|
140
|
+
Tina4::Router.add("PUT", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
141
141
|
begin
|
|
142
142
|
id = req.params["id"]
|
|
143
143
|
record = model_class.find_by_id(id.to_i)
|
|
@@ -175,7 +175,7 @@ module Tina4
|
|
|
175
175
|
})
|
|
176
176
|
|
|
177
177
|
# DELETE /api/{table}/{id} -- delete record
|
|
178
|
-
Tina4::Router.
|
|
178
|
+
Tina4::Router.add("DELETE", "#{prefix}/#{table}/{id}", proc { |req, res|
|
|
179
179
|
begin
|
|
180
180
|
id = req.params["id"]
|
|
181
181
|
record = model_class.find_by_id(id.to_i)
|
data/lib/tina4/cache.rb
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
module Tina4
|
|
2
|
+
# In-memory TTL cache with tag-based invalidation.
|
|
3
|
+
#
|
|
4
|
+
# Matches the Python / PHP / Node.js QueryCache API for cross-framework
|
|
5
|
+
# parity. Thread-safe via an internal Mutex.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# cache = Tina4::QueryCache.new(default_ttl: 60, max_size: 1000)
|
|
9
|
+
# cache.set("key", "value", ttl: 30, tags: ["users"])
|
|
10
|
+
# cache.get("key") # => "value"
|
|
11
|
+
# cache.clear_tag("users")
|
|
12
|
+
#
|
|
13
|
+
class QueryCache
|
|
14
|
+
CacheEntry = Struct.new(:value, :expires_at, :tags)
|
|
15
|
+
|
|
16
|
+
# @param default_ttl [Integer] default TTL in seconds (default: 300)
|
|
17
|
+
# @param max_size [Integer] maximum number of cache entries (default: 1000)
|
|
18
|
+
def initialize(default_ttl: 300, max_size: 1000)
|
|
19
|
+
@default_ttl = default_ttl
|
|
20
|
+
@max_size = max_size
|
|
21
|
+
@store = {}
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Store a value with optional TTL and tags.
|
|
26
|
+
#
|
|
27
|
+
# @param key [String]
|
|
28
|
+
# @param value [Object]
|
|
29
|
+
# @param ttl [Integer, nil] TTL in seconds (nil uses default)
|
|
30
|
+
# @param tags [Array<String>] optional tags for grouped invalidation
|
|
31
|
+
def set(key, value, ttl: nil, tags: [])
|
|
32
|
+
ttl ||= @default_ttl
|
|
33
|
+
expires_at = Time.now.to_f + ttl
|
|
34
|
+
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
# Evict oldest if at capacity
|
|
37
|
+
if @store.size >= @max_size && !@store.key?(key)
|
|
38
|
+
oldest_key = @store.keys.first
|
|
39
|
+
@store.delete(oldest_key)
|
|
40
|
+
end
|
|
41
|
+
@store[key] = CacheEntry.new(value, expires_at, tags)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Retrieve a cached value. Returns nil if expired or missing.
|
|
46
|
+
#
|
|
47
|
+
# @param key [String]
|
|
48
|
+
# @param default [Object] value to return if key is missing
|
|
49
|
+
# @return [Object, nil]
|
|
50
|
+
def get(key, default = nil)
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
entry = @store[key]
|
|
53
|
+
return default unless entry
|
|
54
|
+
|
|
55
|
+
if Time.now.to_f > entry.expires_at
|
|
56
|
+
@store.delete(key)
|
|
57
|
+
return default
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
entry.value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Check if a key exists and is not expired.
|
|
65
|
+
#
|
|
66
|
+
# @param key [String]
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def has?(key)
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
entry = @store[key]
|
|
71
|
+
return false unless entry
|
|
72
|
+
|
|
73
|
+
if Time.now.to_f > entry.expires_at
|
|
74
|
+
@store.delete(key)
|
|
75
|
+
return false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Delete a key from the cache.
|
|
83
|
+
#
|
|
84
|
+
# @param key [String]
|
|
85
|
+
# @return [Boolean] true if the key was present
|
|
86
|
+
def delete(key)
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
!@store.delete(key).nil?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Clear all entries from the cache.
|
|
93
|
+
def clear
|
|
94
|
+
@mutex.synchronize { @store.clear }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Clear all entries with a given tag.
|
|
98
|
+
#
|
|
99
|
+
# @param tag [String]
|
|
100
|
+
# @return [Integer] number of entries removed
|
|
101
|
+
def clear_tag(tag)
|
|
102
|
+
@mutex.synchronize do
|
|
103
|
+
keys_to_remove = @store.select { |_k, v| v.tags.include?(tag) }.keys
|
|
104
|
+
keys_to_remove.each { |k| @store.delete(k) }
|
|
105
|
+
keys_to_remove.size
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Remove all expired entries.
|
|
110
|
+
#
|
|
111
|
+
# @return [Integer] number of entries removed
|
|
112
|
+
def sweep
|
|
113
|
+
@mutex.synchronize do
|
|
114
|
+
now = Time.now.to_f
|
|
115
|
+
keys_to_remove = @store.select { |_k, v| now > v.expires_at }.keys
|
|
116
|
+
keys_to_remove.each { |k| @store.delete(k) }
|
|
117
|
+
keys_to_remove.size
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Fetch from cache, or compute and store.
|
|
122
|
+
#
|
|
123
|
+
# @param key [String]
|
|
124
|
+
# @param ttl [Integer] TTL in seconds
|
|
125
|
+
# @param block [Proc] factory to compute the value if not cached
|
|
126
|
+
# @return [Object]
|
|
127
|
+
def remember(key, ttl, &block)
|
|
128
|
+
cached = get(key)
|
|
129
|
+
return cached unless cached.nil?
|
|
130
|
+
|
|
131
|
+
value = block.call
|
|
132
|
+
set(key, value, ttl: ttl)
|
|
133
|
+
value
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Current number of entries in the cache.
|
|
137
|
+
#
|
|
138
|
+
# @return [Integer]
|
|
139
|
+
def size
|
|
140
|
+
@mutex.synchronize { @store.size }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Generate a stable cache key from a SQL query and params.
|
|
144
|
+
# Mirrors SQLTranslator.query_key for direct use on QueryCache.
|
|
145
|
+
#
|
|
146
|
+
# @param sql [String]
|
|
147
|
+
# @param params [Array, nil]
|
|
148
|
+
# @return [String]
|
|
149
|
+
def self.query_key(sql, params = nil)
|
|
150
|
+
raw = params ? "#{sql}|#{params.inspect}" : sql
|
|
151
|
+
"query:#{Digest::SHA256.hexdigest(raw)}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -368,7 +368,7 @@ module Tina4
|
|
|
368
368
|
require_relative "../tina4"
|
|
369
369
|
Tina4.initialize!(Dir.pwd)
|
|
370
370
|
load_routes(Dir.pwd)
|
|
371
|
-
Tina4.
|
|
371
|
+
Tina4.seed_dir(seed_folder: "seeds", clear: options[:clear])
|
|
372
372
|
end
|
|
373
373
|
|
|
374
374
|
# ── seed:create ───────────────────────────────────────────────────────
|
data/lib/tina4/container.rb
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
module Tina4
|
|
4
4
|
# Lightweight dependency injection container.
|
|
5
5
|
#
|
|
6
|
-
# Tina4::Container.register(:mailer) { MailService.new } # transient — new instance each
|
|
6
|
+
# Tina4::Container.register(:mailer) { MailService.new } # transient — new instance each get
|
|
7
7
|
# Tina4::Container.singleton(:db) { Database.new(ENV["DB_URL"]) } # singleton — memoised
|
|
8
8
|
# Tina4::Container.register(:cache, RedisCacheInstance) # concrete instance (always same)
|
|
9
|
-
# Tina4::Container.
|
|
9
|
+
# Tina4::Container.get(:db) # => Database instance
|
|
10
10
|
#
|
|
11
11
|
module Container
|
|
12
12
|
class << self
|
|
@@ -16,7 +16,7 @@ module Tina4
|
|
|
16
16
|
|
|
17
17
|
# Register a service by name.
|
|
18
18
|
# Pass a concrete instance directly, or a block for transient instantiation.
|
|
19
|
-
# Blocks are called on every
|
|
19
|
+
# Blocks are called on every get() — use singleton() for memoised factories.
|
|
20
20
|
def register(name, instance = nil, &factory)
|
|
21
21
|
raise ArgumentError, "provide an instance or a block, not both" if instance && factory
|
|
22
22
|
raise ArgumentError, "provide an instance or a block" unless instance || factory
|
|
@@ -39,7 +39,7 @@ module Tina4
|
|
|
39
39
|
# Resolve a service by name.
|
|
40
40
|
# Singletons and concrete instances return the same object each time.
|
|
41
41
|
# Transient factories (register with block) return a new object each time.
|
|
42
|
-
def
|
|
42
|
+
def get(name)
|
|
43
43
|
entry = registry[name.to_sym]
|
|
44
44
|
raise KeyError, "service not registered: #{name}" unless entry
|
|
45
45
|
|
|
@@ -61,12 +61,12 @@ module Tina4
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Check if a service is registered.
|
|
64
|
-
def
|
|
64
|
+
def has(name)
|
|
65
65
|
registry.key?(name.to_sym)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# Remove all registrations (useful in tests).
|
|
69
|
-
def
|
|
69
|
+
def reset
|
|
70
70
|
@registry = {}
|
|
71
71
|
end
|
|
72
72
|
end
|
data/lib/tina4/crud.rb
CHANGED
|
@@ -211,7 +211,7 @@ module Tina4
|
|
|
211
211
|
|
|
212
212
|
# GET list (already handled by the page itself)
|
|
213
213
|
# POST create
|
|
214
|
-
Tina4::Router.
|
|
214
|
+
Tina4::Router.add("POST", api_path, proc { |req, res|
|
|
215
215
|
begin
|
|
216
216
|
data = req.body_parsed
|
|
217
217
|
result = db.insert(table_name, data)
|
|
@@ -222,7 +222,7 @@ module Tina4
|
|
|
222
222
|
})
|
|
223
223
|
|
|
224
224
|
# PUT update
|
|
225
|
-
Tina4::Router.
|
|
225
|
+
Tina4::Router.add("PUT", "#{api_path}/{id}", proc { |req, res|
|
|
226
226
|
begin
|
|
227
227
|
id = req.params["id"]
|
|
228
228
|
data = req.body_parsed
|
|
@@ -234,7 +234,7 @@ module Tina4
|
|
|
234
234
|
})
|
|
235
235
|
|
|
236
236
|
# DELETE
|
|
237
|
-
Tina4::Router.
|
|
237
|
+
Tina4::Router.add("DELETE", "#{api_path}/{id}", proc { |req, res|
|
|
238
238
|
begin
|
|
239
239
|
id = req.params["id"]
|
|
240
240
|
db.delete(table_name, { pk => id })
|
data/lib/tina4/database.rb
CHANGED
|
@@ -82,6 +82,25 @@ module Tina4
|
|
|
82
82
|
"odbc" => "Tina4::Drivers::OdbcDriver"
|
|
83
83
|
}.freeze
|
|
84
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
|
+
|
|
85
104
|
def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: 0)
|
|
86
105
|
@connection_string = connection_string || ENV["DATABASE_URL"]
|
|
87
106
|
@username = username || ENV["DATABASE_USERNAME"]
|
|
@@ -241,10 +260,19 @@ module Tina4
|
|
|
241
260
|
{ success: true, last_id: drv.last_insert_id }
|
|
242
261
|
end
|
|
243
262
|
|
|
244
|
-
def update(table, data, filter = {})
|
|
263
|
+
def update(table, data, filter = {}, params = nil)
|
|
245
264
|
cache_invalidate if @cache_enabled
|
|
246
265
|
drv = current_driver
|
|
247
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
|
+
|
|
248
276
|
set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
249
277
|
where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
250
278
|
sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
|
|
@@ -254,7 +282,7 @@ module Tina4
|
|
|
254
282
|
{ success: true }
|
|
255
283
|
end
|
|
256
284
|
|
|
257
|
-
def delete(table, filter = {})
|
|
285
|
+
def delete(table, filter = {}, params = nil)
|
|
258
286
|
cache_invalidate if @cache_enabled
|
|
259
287
|
drv = current_driver
|
|
260
288
|
|
|
@@ -264,11 +292,11 @@ module Tina4
|
|
|
264
292
|
return { success: true }
|
|
265
293
|
end
|
|
266
294
|
|
|
267
|
-
# String filter — raw WHERE clause
|
|
295
|
+
# String filter — raw WHERE clause with optional params
|
|
268
296
|
if filter.is_a?(String)
|
|
269
297
|
sql = "DELETE FROM #{table}"
|
|
270
298
|
sql += " WHERE #{filter}" unless filter.empty?
|
|
271
|
-
drv.execute(sql)
|
|
299
|
+
drv.execute(sql, Array(params))
|
|
272
300
|
return { success: true }
|
|
273
301
|
end
|
|
274
302
|
|
|
@@ -335,18 +363,42 @@ module Tina4
|
|
|
335
363
|
raise e
|
|
336
364
|
end
|
|
337
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
|
+
|
|
338
381
|
def tables
|
|
339
382
|
current_driver.tables
|
|
340
383
|
end
|
|
341
384
|
|
|
385
|
+
# Cross-framework alias for tables — matches PHP/Python/Node get_tables.
|
|
386
|
+
alias get_tables tables
|
|
387
|
+
|
|
342
388
|
def columns(table_name)
|
|
343
389
|
current_driver.columns(table_name)
|
|
344
390
|
end
|
|
345
391
|
|
|
392
|
+
# Cross-framework alias for columns — matches PHP/Python/Node get_columns.
|
|
393
|
+
alias get_columns columns
|
|
394
|
+
|
|
346
395
|
def table_exists?(table_name)
|
|
347
396
|
tables.any? { |t| t.downcase == table_name.to_s.downcase }
|
|
348
397
|
end
|
|
349
398
|
|
|
399
|
+
# Cross-framework alias for table_exists? — matches PHP/Python/Node table_exists.
|
|
400
|
+
alias table_exists table_exists?
|
|
401
|
+
|
|
350
402
|
# Pre-generate the next available primary key ID using engine-aware strategies.
|
|
351
403
|
#
|
|
352
404
|
# Race-safe implementation using a `tina4_sequences` table for SQLite/MySQL/MSSQL
|
|
@@ -362,6 +414,40 @@ module Tina4
|
|
|
362
414
|
# @param pk_column [String] Primary key column name (default: "id")
|
|
363
415
|
# @param generator_name [String, nil] Override for sequence/generator name
|
|
364
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
|
+
|
|
365
451
|
def get_next_id(table, pk_column: "id", generator_name: nil)
|
|
366
452
|
drv = current_driver
|
|
367
453
|
|
|
@@ -8,6 +8,10 @@ module Tina4
|
|
|
8
8
|
def connect(connection_string, username: nil, password: nil)
|
|
9
9
|
require "sqlite3"
|
|
10
10
|
db_path = connection_string.sub(/^sqlite:\/\//, "").sub(/^sqlite:/, "")
|
|
11
|
+
# Windows: sqlite:///C:/Users/app.db → /C:/Users/app.db after stripping scheme.
|
|
12
|
+
# The leading / before the drive letter must be removed.
|
|
13
|
+
db_path = db_path[1..] if db_path.match?(/^\/[A-Za-z]:/)
|
|
14
|
+
|
|
11
15
|
@connection = SQLite3::Database.new(db_path)
|
|
12
16
|
@connection.results_as_hash = true
|
|
13
17
|
@connection.execute("PRAGMA journal_mode=WAL")
|
data/lib/tina4/frond.rb
CHANGED
|
@@ -1931,6 +1931,14 @@ module Tina4
|
|
|
1931
1931
|
|
|
1932
1932
|
class << self
|
|
1933
1933
|
attr_accessor :form_token_session_id
|
|
1934
|
+
|
|
1935
|
+
# Set the session ID used for CSRF form token binding.
|
|
1936
|
+
# Parity with Python/PHP/Node: Frond.set_form_token_session_id(id)
|
|
1937
|
+
#
|
|
1938
|
+
# @param session_id [String] The session ID to bind form tokens to
|
|
1939
|
+
def set_form_token_session_id(session_id)
|
|
1940
|
+
self.form_token_session_id = session_id
|
|
1941
|
+
end
|
|
1934
1942
|
end
|
|
1935
1943
|
|
|
1936
1944
|
# Generate a raw JWT form token string.
|