tina4ruby 3.13.93 → 3.13.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +35 -43
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +458 -48
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dev_mailbox.rb +5 -1
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +25 -3
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/frond.rb +102 -10
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +317 -82
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +243 -29
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +301 -38
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/middleware.rb
CHANGED
|
@@ -35,6 +35,32 @@ module Tina4
|
|
|
35
35
|
global_middleware << klass unless global_middleware.include?(klass)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Global middleware that runs BEFORE route matching.
|
|
39
|
+
#
|
|
40
|
+
# A middleware opts in by declaring `def self.pre_match?; true; end`.
|
|
41
|
+
#
|
|
42
|
+
# NOT `before_match?` - the hook discovery treats every `before_*` method
|
|
43
|
+
# as a middleware hook and calls it with (request, response), so that name
|
|
44
|
+
# made the flag itself run as middleware and 500 the request.
|
|
45
|
+
# Everything else stays where it has always run - after matching - so
|
|
46
|
+
# this is additive and no existing middleware changes behaviour.
|
|
47
|
+
#
|
|
48
|
+
# The split exists because the two groups need opposite things. CORS must
|
|
49
|
+
# run before matching so its headers survive a short-circuited 401/403;
|
|
50
|
+
# a browser that gets a 401 without them reports a CORS error and the real
|
|
51
|
+
# status is invisible. CSRF must run AFTER, because it reads the matched
|
|
52
|
+
# route's metadata to honour a route marked no_auth - PHP shipped exactly
|
|
53
|
+
# that bypass as dead code once, because the metadata was not set yet.
|
|
54
|
+
def pre_match_middleware
|
|
55
|
+
global_middleware.select { |k| k.respond_to?(:pre_match?) && k.pre_match? }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Global middleware that runs after matching, once the matched route's
|
|
59
|
+
# metadata is readable. This is the default.
|
|
60
|
+
def post_match_middleware
|
|
61
|
+
global_middleware.reject { |k| k.respond_to?(:pre_match?) && k.pre_match? }
|
|
62
|
+
end
|
|
63
|
+
|
|
38
64
|
def clear!
|
|
39
65
|
@before_handlers = []
|
|
40
66
|
@after_handlers = []
|
|
@@ -47,17 +73,33 @@ module Tina4
|
|
|
47
73
|
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
48
74
|
# middleware classes explicitly.
|
|
49
75
|
#
|
|
76
|
+
# THE RETURN-VALUE CONTRACT is #apply_before_result below — one table,
|
|
77
|
+
# applied to EVERY before_* hook at EVERY scope. Per-route middleware
|
|
78
|
+
# comes through this same method (Tina4::Route#run_middleware), so there
|
|
79
|
+
# is exactly one implementation of the table, not two.
|
|
80
|
+
#
|
|
50
81
|
# M2 — visible-but-resilient: every before_* call is wrapped so a THROW
|
|
51
82
|
# never crashes the worker. On a throw the error is LOGGED and the
|
|
52
83
|
# response becomes a clean 500 ({"error":"Internal Server Error",
|
|
53
84
|
# "status":500}), then processing halts (handler skipped) — deterministic,
|
|
54
|
-
# never an unhandled exception.
|
|
55
|
-
#
|
|
56
|
-
# halt path (see the dispatcher / #run_after docstring).
|
|
85
|
+
# never an unhandled exception. after_* still run on either halt path
|
|
86
|
+
# (see the dispatcher / #run_after docstring).
|
|
57
87
|
#
|
|
58
88
|
# Returns true on success, or false to halt the request (handler skipped).
|
|
59
89
|
def run_before(middleware_classes, request, response)
|
|
60
|
-
#
|
|
90
|
+
# The response object the CALLER holds. A hook may hand back a
|
|
91
|
+
# different Response; on a halt that object IS the answer, so its state
|
|
92
|
+
# is adopted onto this one before returning — otherwise the dispatcher
|
|
93
|
+
# would serve the object it still has a reference to and the
|
|
94
|
+
# short-circuit would silently vanish.
|
|
95
|
+
origin = response
|
|
96
|
+
|
|
97
|
+
# 1. Block-based before handlers (pattern-matched). These are a
|
|
98
|
+
# Ruby-only surface (Python/PHP/Node have no block form) and keep
|
|
99
|
+
# their historical "false halts" contract: a block's value is its
|
|
100
|
+
# last expression, so reading a returned Response as a
|
|
101
|
+
# short-circuit would fire on any block ending in a chainable
|
|
102
|
+
# response call.
|
|
61
103
|
before_handlers.each do |entry|
|
|
62
104
|
next unless matches_pattern?(request.path, entry[:pattern])
|
|
63
105
|
|
|
@@ -79,14 +121,12 @@ module Tina4
|
|
|
79
121
|
middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
|
|
80
122
|
return false
|
|
81
123
|
end
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return false if response.status_code >= 400
|
|
89
|
-
end
|
|
124
|
+
|
|
125
|
+
halt, request, response = apply_before_result(result, request, response)
|
|
126
|
+
next unless halt
|
|
127
|
+
|
|
128
|
+
adopt_response(origin, response) unless response.equal?(origin)
|
|
129
|
+
return false
|
|
90
130
|
end
|
|
91
131
|
end
|
|
92
132
|
|
|
@@ -108,7 +148,17 @@ module Tina4
|
|
|
108
148
|
# M2 — every after_* call is wrapped: a THROW is LOGGED and turns the
|
|
109
149
|
# response into a clean 500, then the REMAINING after_* still run (they
|
|
110
150
|
# may add headers/logging). Never an unhandled crash.
|
|
151
|
+
#
|
|
152
|
+
# RETURN VALUES: an after_* hook shapes the response the same way a
|
|
153
|
+
# before_* one does — a returned Tina4::Response BECOMES the response, a
|
|
154
|
+
# returned [request, response] pair rebinds both. What it CANNOT do is
|
|
155
|
+
# halt: the handler has already run, so there is nothing left to skip,
|
|
156
|
+
# and stopping the remaining after_* would contradict the AFTER-ON-4xx
|
|
157
|
+
# resilience rule above (they exist to add headers/logging on every path).
|
|
158
|
+
# So `false` and a >= 400 status are inert here by design.
|
|
111
159
|
def run_after(middleware_classes, request, response)
|
|
160
|
+
origin = response
|
|
161
|
+
|
|
112
162
|
# 1. Block-based after handlers (pattern-matched)
|
|
113
163
|
after_handlers.each do |entry|
|
|
114
164
|
next unless matches_pattern?(request.path, entry[:pattern])
|
|
@@ -129,11 +179,16 @@ module Tina4
|
|
|
129
179
|
middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
|
|
130
180
|
next
|
|
131
181
|
end
|
|
132
|
-
if result.is_a?(
|
|
182
|
+
if result.is_a?(Tina4::Response)
|
|
183
|
+
response = result
|
|
184
|
+
elsif result.is_a?(Array) && result.length == 2
|
|
133
185
|
request, response = result
|
|
134
186
|
end
|
|
135
187
|
end
|
|
136
188
|
end
|
|
189
|
+
|
|
190
|
+
adopt_response(origin, response) unless response.equal?(origin)
|
|
191
|
+
response
|
|
137
192
|
end
|
|
138
193
|
|
|
139
194
|
# Deterministic clean 500 for a middleware that threw. Logs the cause
|
|
@@ -156,8 +211,118 @@ module Tina4
|
|
|
156
211
|
response.json({ error: "Internal Server Error", status: 500 }, 500)
|
|
157
212
|
end
|
|
158
213
|
|
|
214
|
+
# The `false` row of the return-value table, on its own.
|
|
215
|
+
#
|
|
216
|
+
# A middleware that halts by returning false keeps the response it set;
|
|
217
|
+
# only a response still left default/empty becomes a 403. Public because
|
|
218
|
+
# per-route "filter" middleware (a 2-arg callable returning false, see
|
|
219
|
+
# Tina4::Route#run_middleware) must obey the SAME row as a before_* hook,
|
|
220
|
+
# and the rule should exist exactly once.
|
|
221
|
+
def refuse(response)
|
|
222
|
+
forbid(response) if default_response?(response)
|
|
223
|
+
response
|
|
224
|
+
end
|
|
225
|
+
|
|
159
226
|
private
|
|
160
227
|
|
|
228
|
+
# ── THE BEFORE-HOOK RETURN-VALUE TABLE ────────────────────────────────
|
|
229
|
+
#
|
|
230
|
+
# Interpret ONE before_* hook's return value. Identical in Python, PHP,
|
|
231
|
+
# Ruby and Node, and applied at EVERY scope — global (Router.use) and
|
|
232
|
+
# per-route (route.middleware) both land here.
|
|
233
|
+
#
|
|
234
|
+
# a Tina4::Response SHORT-CIRCUIT. That object IS the response, at
|
|
235
|
+
# ANY status. This is the PRIMARY rule: it is the
|
|
236
|
+
# only one that can express a 302 redirect.
|
|
237
|
+
# [request, response] rebind both, continue
|
|
238
|
+
# false SHORT-CIRCUIT. Send the response AS SET; only
|
|
239
|
+
# when it is still default/empty does it become a
|
|
240
|
+
# 403. (Per-route middleware used to answer a
|
|
241
|
+
# halt with a HARDCODED 403 that threw away
|
|
242
|
+
# whatever the middleware had set — that is gone.)
|
|
243
|
+
# nil / anything else continue
|
|
244
|
+
#
|
|
245
|
+
# LEGACY COMPATIBILITY PATH (retained, deliberately NOT the main
|
|
246
|
+
# mechanism): after the hook returns, a response status >= 400 also
|
|
247
|
+
# short-circuits, even when the hook returned nil. Middleware written
|
|
248
|
+
# before the Response rule existed signals refusal that way, so it stays
|
|
249
|
+
# honoured — but it cannot express a 3xx redirect, which is exactly why
|
|
250
|
+
# the Response rule above is primary and this one is the fallback.
|
|
251
|
+
#
|
|
252
|
+
# This check used to be nested INSIDE the "returned a 2-element Array"
|
|
253
|
+
# branch, so a hook that set 403 and returned nil was ignored and the
|
|
254
|
+
# handler RAN — an auth middleware that refused without returning the pair
|
|
255
|
+
# was a no-op. Python, PHP and Node all check the status unconditionally
|
|
256
|
+
# after the call; Rails short-circuits on the response STATE, not on what
|
|
257
|
+
# the filter returned. Now it is unconditional here too.
|
|
258
|
+
#
|
|
259
|
+
# Returns [halt?, request, response].
|
|
260
|
+
def apply_before_result(result, request, response)
|
|
261
|
+
if result.is_a?(Tina4::Response)
|
|
262
|
+
return [true, request, result]
|
|
263
|
+
elsif result.is_a?(Array) && result.length == 2
|
|
264
|
+
request, response = result
|
|
265
|
+
elsif result == false
|
|
266
|
+
refuse(response)
|
|
267
|
+
return [true, request, response]
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
status = status_of(response)
|
|
271
|
+
return [true, request, response] if status.is_a?(Integer) && status >= 400
|
|
272
|
+
|
|
273
|
+
[false, request, response]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Read a response's status defensively — a middleware may hand back any
|
|
277
|
+
# response-shaped object. Mirrors the Python master's
|
|
278
|
+
# `getattr(response, "status_code", None) or getattr(response, "status", 0)`.
|
|
279
|
+
def status_of(response)
|
|
280
|
+
return response.status_code if response.respond_to?(:status_code)
|
|
281
|
+
return response.status if response.respond_to?(:status)
|
|
282
|
+
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Has this response been left untouched? Only then does a `false` return
|
|
287
|
+
# get turned into a 403 — a middleware that already answered keeps its
|
|
288
|
+
# own answer.
|
|
289
|
+
def default_response?(response)
|
|
290
|
+
status = status_of(response)
|
|
291
|
+
return false unless status.nil? || status == 200
|
|
292
|
+
|
|
293
|
+
body = response.respond_to?(:body) ? response.body : nil
|
|
294
|
+
body.to_s.empty?
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# The canonical refusal, in the same shape as #middleware_500 so the two
|
|
298
|
+
# framework-generated error bodies match byte for byte across all four
|
|
299
|
+
# frameworks.
|
|
300
|
+
def forbid(response)
|
|
301
|
+
if response.respond_to?(:json)
|
|
302
|
+
response.json({ error: "Forbidden", status: 403 }, 403)
|
|
303
|
+
elsif response.respond_to?(:status_code=)
|
|
304
|
+
response.status_code = 403
|
|
305
|
+
end
|
|
306
|
+
response
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Copy a response's state onto the object the CALLER still holds.
|
|
310
|
+
#
|
|
311
|
+
# Ruby passes references, so a hook that MUTATES the response it was given
|
|
312
|
+
# needs nothing from us. This exists for the hook that hands back a
|
|
313
|
+
# DIFFERENT Response object: the contract says that object IS the
|
|
314
|
+
# response, and the dispatcher only ever serves the one it passed in.
|
|
315
|
+
# Applied on the halt paths, where the response is the answer.
|
|
316
|
+
def adopt_response(target, source)
|
|
317
|
+
return target unless target.is_a?(Tina4::Response) && source.is_a?(Tina4::Response)
|
|
318
|
+
|
|
319
|
+
target.status_code = source.status_code
|
|
320
|
+
target.headers = source.headers
|
|
321
|
+
target.body = source.body
|
|
322
|
+
target.cookies = source.cookies
|
|
323
|
+
target
|
|
324
|
+
end
|
|
325
|
+
|
|
161
326
|
# Human-readable label for a middleware (class name, or the class of an
|
|
162
327
|
# instance) used in the logged 500 message.
|
|
163
328
|
def class_label(klass)
|
|
@@ -249,54 +414,24 @@ module Tina4
|
|
|
249
414
|
# ---------------------------------------------------------------------------
|
|
250
415
|
|
|
251
416
|
# CorsClassMiddleware -- sets CORS headers from env vars on every response.
|
|
252
|
-
#
|
|
417
|
+
#
|
|
418
|
+
# A thin adapter over Tina4::CorsMiddleware, which owns the whole policy.
|
|
419
|
+
# It used to be a SECOND, independent implementation of the same rules and
|
|
420
|
+
# the two had already drifted: this copy had no wildcard/credentials guard,
|
|
421
|
+
# fell back to the Referer header (a full URL, not an origin), and on an
|
|
422
|
+
# allow-list MISS returned `allowed.first` - stamping some OTHER allowed
|
|
423
|
+
# origin onto the response of an origin that was not allowed at all. One
|
|
424
|
+
# feature, one implementation.
|
|
253
425
|
class CorsClassMiddleware
|
|
254
426
|
class << self
|
|
255
427
|
def before_cors(request, response)
|
|
256
|
-
|
|
257
|
-
origin =
|
|
258
|
-
|
|
259
|
-
response.headers["access-control-allow-origin"] = origin
|
|
260
|
-
response.headers["access-control-allow-methods"] = config[:methods]
|
|
261
|
-
response.headers["access-control-allow-headers"] = config[:headers]
|
|
262
|
-
response.headers["access-control-max-age"] = config[:max_age]
|
|
263
|
-
if config[:credentials] == "true"
|
|
264
|
-
response.headers["access-control-allow-credentials"] = "true"
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
[request, response]
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
private
|
|
428
|
+
env = request.respond_to?(:env) && request.env ? request.env : {}
|
|
429
|
+
origin = request.headers["origin"] if request.respond_to?(:headers)
|
|
430
|
+
env = env.merge("HTTP_ORIGIN" => origin) if origin
|
|
271
431
|
|
|
272
|
-
|
|
273
|
-
{
|
|
274
|
-
origins: ENV["TINA4_CORS_ORIGINS"] || "*",
|
|
275
|
-
methods: ENV["TINA4_CORS_METHODS"] || "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
276
|
-
headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,Authorization,X-Request-ID",
|
|
277
|
-
max_age: ENV["TINA4_CORS_MAX_AGE"] || "86400",
|
|
278
|
-
credentials: ENV["TINA4_CORS_CREDENTIALS"] || "false"
|
|
279
|
-
}
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
def is_preflight(request)
|
|
283
|
-
request.method&.upcase == "OPTIONS" &&
|
|
284
|
-
request.headers["origin"] &&
|
|
285
|
-
request.headers["access-control-request-method"]
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
def resolve_origin(request, config)
|
|
289
|
-
request_origin = request.headers["origin"] || request.headers["referer"]
|
|
432
|
+
Tina4::CorsMiddleware.apply_headers(response.headers, env)
|
|
290
433
|
|
|
291
|
-
|
|
292
|
-
"*"
|
|
293
|
-
elsif request_origin
|
|
294
|
-
allowed = config[:origins].split(",").map(&:strip)
|
|
295
|
-
clean = request_origin.chomp("/")
|
|
296
|
-
allowed.include?(clean) ? clean : allowed.first || "*"
|
|
297
|
-
else
|
|
298
|
-
config[:origins].split(",").first&.strip || "*"
|
|
299
|
-
end
|
|
434
|
+
[request, response]
|
|
300
435
|
end
|
|
301
436
|
end
|
|
302
437
|
end
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -71,7 +71,23 @@ module Tina4
|
|
|
71
71
|
created_at = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
72
72
|
safe_name = description.gsub(/[^a-z0-9]+/i, "_").downcase.gsub(/^_|_$/, "")
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
# MEASURED 2026-08-06: the accepted kind differed in every framework -
|
|
75
|
+
# python "python", php "php", ruby "ruby" OR "python", node "class" - and
|
|
76
|
+
# NONE validated it, so create_migration(..., kind="python") produced a
|
|
77
|
+
# code migration in Python and Ruby and a SILENT .sql file in PHP and
|
|
78
|
+
# Node. "code" is now the canonical spelling in all four; each keeps its
|
|
79
|
+
# own language name as a legacy alias; anything else raises.
|
|
80
|
+
# Ruby also accepted "python", which was a copy-paste from the master
|
|
81
|
+
# and is dropped: a Ruby project never wants a .py migration.
|
|
82
|
+
kind = (kind || "sql").to_s.strip.downcase
|
|
83
|
+
unless %w[sql code ruby].include?(kind)
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"Unknown migration kind #{kind.inspect}. Use 'sql' (default) or " \
|
|
86
|
+
"'code' (alias: 'ruby'). An unrecognised kind used to produce a " \
|
|
87
|
+
".sql file silently, which is why this now raises."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if %w[code ruby].include?(kind)
|
|
75
91
|
filename = "#{timestamp}_#{safe_name}.rb"
|
|
76
92
|
filepath = File.join(@migrations_dir, filename)
|
|
77
93
|
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -120,6 +120,22 @@ module Tina4
|
|
|
120
120
|
# Auto-map flag — defaults to TRUE for cross-framework parity (Python's
|
|
121
121
|
# ORM has auto_map=True by default). The instance variable is treated
|
|
122
122
|
# as "unset" when nil; only an explicit `false` disables it.
|
|
123
|
+
#
|
|
124
|
+
# INERT IN RUBY, DELIBERATELY. Nothing reads this flag: Ruby is
|
|
125
|
+
# snake_case-native, so the attribute name a developer writes IS the
|
|
126
|
+
# column name and there is nothing for a case mapping to do. It exists
|
|
127
|
+
# only so a model ported from PHP (where `autoMap` really does map a
|
|
128
|
+
# camelCase property onto a snake_case column) does not blow up on an
|
|
129
|
+
# unknown setter. Setting it either way changes NOTHING.
|
|
130
|
+
#
|
|
131
|
+
# This is not an oversight to "fix" by adding conversion: the owner's
|
|
132
|
+
# naming rule (2026-07-29) is that the column name must mirror the
|
|
133
|
+
# DATABASE, and a language-specific case mapping may only ever be an
|
|
134
|
+
# OPT-IN. Adding camel->snake here by default would be that mapping, on
|
|
135
|
+
# by default, which is the opposite. Use `field_mapping` to point an
|
|
136
|
+
# attribute at a differently-named column — that is the supported
|
|
137
|
+
# mechanism, and spec/orm_column_case_spec.rb pins all of this so the
|
|
138
|
+
# flag cannot quietly grow behaviour later.
|
|
123
139
|
def auto_map
|
|
124
140
|
defined?(@auto_map) && !@auto_map.nil? ? @auto_map : true
|
|
125
141
|
end
|
|
@@ -302,7 +318,7 @@ module Tina4
|
|
|
302
318
|
end
|
|
303
319
|
end
|
|
304
320
|
|
|
305
|
-
def where(conditions, params = [], limit:
|
|
321
|
+
def where(conditions, params = [], limit: 100, offset: nil, order_by: nil, include: nil)
|
|
306
322
|
sql = "SELECT * FROM #{table_name}"
|
|
307
323
|
if soft_delete
|
|
308
324
|
sql += " WHERE (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0) AND (#{conditions})"
|
|
@@ -316,7 +332,7 @@ module Tina4
|
|
|
316
332
|
instances
|
|
317
333
|
end
|
|
318
334
|
|
|
319
|
-
def all(limit:
|
|
335
|
+
def all(limit: 100, offset: nil, order_by: nil, include: nil)
|
|
320
336
|
sql = "SELECT * FROM #{table_name}"
|
|
321
337
|
if soft_delete
|
|
322
338
|
sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0"
|
|
@@ -328,7 +344,7 @@ module Tina4
|
|
|
328
344
|
instances
|
|
329
345
|
end
|
|
330
346
|
|
|
331
|
-
def select(sql, params = [], limit:
|
|
347
|
+
def select(sql, params = [], limit: 100, offset: nil, include: nil)
|
|
332
348
|
results = db.fetch(sql, params, limit: limit, offset: offset)
|
|
333
349
|
instances = results.map { |row| from_hash(row) }
|
|
334
350
|
eager_load(instances, include) if include
|
|
@@ -340,6 +356,51 @@ module Tina4
|
|
|
340
356
|
results.first
|
|
341
357
|
end
|
|
342
358
|
|
|
359
|
+
# The ONE process-wide query cache, shared by every model.
|
|
360
|
+
#
|
|
361
|
+
# The Python master holds this as a module-level `_query_cache =
|
|
362
|
+
# Cache(default_ttl=0, max_size=500)` in orm/model.py, so every model shares
|
|
363
|
+
# a single store. A plain `@query_cache ||=` here would NOT be that
|
|
364
|
+
# contract: `class << self` ivars are per-class, so each subclass would get
|
|
365
|
+
# its own cache and User.clear_cache would silently leave Order's entries
|
|
366
|
+
# alone. Anchoring the ivar on ORM itself keeps one store for all models,
|
|
367
|
+
# however deep the subclass.
|
|
368
|
+
def query_cache
|
|
369
|
+
ORM.instance_variable_get(:@query_cache) ||
|
|
370
|
+
ORM.instance_variable_set(:@query_cache, QueryCache.new(default_ttl: 0, max_size: 500))
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# SQL query with result caching. Returns an array of ORM instances.
|
|
374
|
+
#
|
|
375
|
+
# Parity with the Python master's `cached` (orm/model.py:1077): same key
|
|
376
|
+
# shape, same tag, and a miss delegates to `select` so eager loading and the
|
|
377
|
+
# row cap behave identically to an uncached read.
|
|
378
|
+
#
|
|
379
|
+
# Entries are tagged with the model name so #clear_cache invalidates only
|
|
380
|
+
# this model's queries and leaves every other model's cached reads intact.
|
|
381
|
+
def cached(sql, params = [], ttl: 60, limit: 100, offset: nil, include: nil)
|
|
382
|
+
key = "#{name}:#{QueryCache.query_key(sql, params)}:#{limit}:#{offset || 0}"
|
|
383
|
+
|
|
384
|
+
# nil-check, NOT a truthiness check: a query that legitimately returns no
|
|
385
|
+
# rows caches an empty array, and that is a HIT. Treating it as a miss
|
|
386
|
+
# would re-run the query on every call for exactly the queries where
|
|
387
|
+
# caching pays off most.
|
|
388
|
+
hit = query_cache.get(key)
|
|
389
|
+
return hit unless hit.nil?
|
|
390
|
+
|
|
391
|
+
result = select(sql, params, limit: limit, offset: offset, include: include)
|
|
392
|
+
query_cache.set(key, result, ttl: ttl, tags: [name])
|
|
393
|
+
result
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Invalidate every cached query for THIS model. Tag-scoped, so it never
|
|
397
|
+
# flushes another model's entries. Mirrors the master's
|
|
398
|
+
# `_query_cache.clear_tag(cls.__name__)`.
|
|
399
|
+
def clear_cache
|
|
400
|
+
query_cache.clear_tag(name)
|
|
401
|
+
nil
|
|
402
|
+
end
|
|
403
|
+
|
|
343
404
|
def count(conditions = nil, params = [])
|
|
344
405
|
sql = "SELECT COUNT(*) as cnt FROM #{table_name}"
|
|
345
406
|
where_parts = []
|
|
@@ -372,7 +433,7 @@ module Tina4
|
|
|
372
433
|
result
|
|
373
434
|
end
|
|
374
435
|
|
|
375
|
-
def with_trashed(conditions = "1=1", params = [], limit:
|
|
436
|
+
def with_trashed(conditions = "1=1", params = [], limit: 100, offset: 0)
|
|
376
437
|
sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
|
|
377
438
|
results = db.fetch(sql, params, limit: limit, offset: offset)
|
|
378
439
|
results.map { |row| from_hash(row) }
|
|
@@ -456,7 +517,11 @@ module Tina4
|
|
|
456
517
|
end
|
|
457
518
|
|
|
458
519
|
parts = ["#{name} #{sql_type}"]
|
|
459
|
-
|
|
520
|
+
# A COMPOSITE key is declared ONCE, at table level (below). An inline
|
|
521
|
+
# PRIMARY KEY per column is invalid DDL - SQLite, PostgreSQL and MySQL
|
|
522
|
+
# all reject two of them in one table, so a composite-key model could
|
|
523
|
+
# not create its own table at all.
|
|
524
|
+
parts << "PRIMARY KEY" if opts[:primary_key] && primary_key_fields.length == 1
|
|
460
525
|
parts << "AUTOINCREMENT" if opts[:auto_increment]
|
|
461
526
|
parts << "NOT NULL" if !opts[:nullable] && !opts[:primary_key]
|
|
462
527
|
# A JSON column carries no DDL DEFAULT (parity with the Python master):
|
|
@@ -474,6 +539,12 @@ module Tina4
|
|
|
474
539
|
col_defs << parts.join(" ")
|
|
475
540
|
end
|
|
476
541
|
|
|
542
|
+
# A COMPOSITE key is declared ONCE, at table level; the per-column inline
|
|
543
|
+
# form above is suppressed for it.
|
|
544
|
+
if primary_key_fields.length > 1
|
|
545
|
+
col_defs << "PRIMARY KEY (#{primary_key_fields.join(', ')})"
|
|
546
|
+
end
|
|
547
|
+
|
|
477
548
|
sql = "CREATE TABLE IF NOT EXISTS #{table_name} (#{col_defs.join(', ')})"
|
|
478
549
|
|
|
479
550
|
# Translate AUTOINCREMENT to the engine's auto-increment syntax
|
|
@@ -499,8 +570,8 @@ module Tina4
|
|
|
499
570
|
end
|
|
500
571
|
|
|
501
572
|
def scope(name, filter_sql, params = [])
|
|
502
|
-
define_singleton_method(name) do |limit:
|
|
503
|
-
where(filter_sql, params)
|
|
573
|
+
define_singleton_method(name) do |limit: 100, offset: 0|
|
|
574
|
+
where(filter_sql, params, limit: limit, offset: offset)
|
|
504
575
|
end
|
|
505
576
|
end
|
|
506
577
|
|
|
@@ -705,6 +776,17 @@ module Tina4
|
|
|
705
776
|
# existence makes the choice correct regardless of @persisted. Auto-increment
|
|
706
777
|
# PKs keep the legacy @persisted-based decision (a nil PK means "new row,
|
|
707
778
|
# let the engine assign an id").
|
|
779
|
+
# A filter hash naming EVERY primary-key column.
|
|
780
|
+
#
|
|
781
|
+
# Addressing a row by one column of a composite key matches every row
|
|
782
|
+
# sharing that value. Feature 4 removed that from the raw write path; this
|
|
783
|
+
# is the same rule for the ORM above it.
|
|
784
|
+
def pk_filter
|
|
785
|
+
self.class.primary_key_fields.each_with_object({}) do |name, acc|
|
|
786
|
+
acc[name] = __send__(name) if respond_to?(name)
|
|
787
|
+
end
|
|
788
|
+
end
|
|
789
|
+
|
|
708
790
|
def save
|
|
709
791
|
@errors = []
|
|
710
792
|
@relationship_cache = {} # Clear relationship cache on save
|
|
@@ -740,7 +822,20 @@ module Tina4
|
|
|
740
822
|
# itself fails (e.g. table missing), fall back to INSERT so the caller
|
|
741
823
|
# sees the real driver error rather than a silent no-op UPDATE.
|
|
742
824
|
begin
|
|
743
|
-
|
|
825
|
+
# This asked exists(pk_value), which tests only ONE key column. On a
|
|
826
|
+
# composite key that is true for any row sharing it, so inserting a
|
|
827
|
+
# genuinely NEW row was decided to be an UPDATE and silently
|
|
828
|
+
# OVERWROTE a different row: saving (acme, a2) rewrote (acme, a1).
|
|
829
|
+
# The probe has to name the whole key, like the write that follows.
|
|
830
|
+
if self.class.primary_key_fields.length > 1
|
|
831
|
+
self.class.where(
|
|
832
|
+
pk_filter.keys.map { |k| "#{k} = ?" }.join(" AND "),
|
|
833
|
+
pk_filter.values,
|
|
834
|
+
limit: 1
|
|
835
|
+
).any?
|
|
836
|
+
else
|
|
837
|
+
self.class.exists(pk_value)
|
|
838
|
+
end
|
|
744
839
|
rescue StandardError
|
|
745
840
|
false
|
|
746
841
|
end
|
|
@@ -756,11 +851,13 @@ module Tina4
|
|
|
756
851
|
# UPDATE is unchanged (#165 targets INSERT only): keep excluding nil
|
|
757
852
|
# so a save never nulls a column the caller didn't touch.
|
|
758
853
|
data = to_db_hash(exclude_nil: true)
|
|
759
|
-
filter =
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
854
|
+
filter = pk_filter
|
|
855
|
+
# Never SET a key column - it is what addresses the row.
|
|
856
|
+
self.class.primary_key_fields.each do |k|
|
|
857
|
+
data.delete(k)
|
|
858
|
+
mapped = self.class.field_mapping[k.to_s]
|
|
859
|
+
data.delete(mapped.to_sym) if mapped
|
|
860
|
+
end
|
|
764
861
|
db.update(self.class.table_name, data, filter)
|
|
765
862
|
else
|
|
766
863
|
# #165: OMIT a column the caller left unset (value nil, never
|
|
@@ -854,10 +951,10 @@ module Tina4
|
|
|
854
951
|
db.update(
|
|
855
952
|
self.class.table_name,
|
|
856
953
|
{ self.class.soft_delete_field => 1 },
|
|
857
|
-
|
|
954
|
+
pk_filter
|
|
858
955
|
)
|
|
859
956
|
else
|
|
860
|
-
db.delete(self.class.table_name,
|
|
957
|
+
db.delete(self.class.table_name, pk_filter)
|
|
861
958
|
end
|
|
862
959
|
end
|
|
863
960
|
@persisted = false
|
|
@@ -870,7 +967,7 @@ module Tina4
|
|
|
870
967
|
raise "Cannot delete: no primary key value" unless pk_value
|
|
871
968
|
|
|
872
969
|
self.class.db.transaction do |db|
|
|
873
|
-
db.delete(self.class.table_name,
|
|
970
|
+
db.delete(self.class.table_name, pk_filter)
|
|
874
971
|
end
|
|
875
972
|
@persisted = false
|
|
876
973
|
true
|
|
@@ -887,7 +984,7 @@ module Tina4
|
|
|
887
984
|
db.update(
|
|
888
985
|
self.class.table_name,
|
|
889
986
|
{ self.class.soft_delete_field => 0 },
|
|
890
|
-
|
|
987
|
+
pk_filter
|
|
891
988
|
)
|
|
892
989
|
end
|
|
893
990
|
__send__("#{self.class.soft_delete_field}=", 0) if respond_to?("#{self.class.soft_delete_field}=")
|