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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +35 -43
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +458 -48
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dev_mailbox.rb +5 -1
  22. data/lib/tina4/dispatch_pipeline.rb +605 -0
  23. data/lib/tina4/docstore.rb +274 -60
  24. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  25. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  26. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  27. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  28. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  29. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  30. data/lib/tina4/drivers/sqlite_driver.rb +25 -3
  31. data/lib/tina4/env.rb +176 -34
  32. data/lib/tina4/field_types.rb +12 -0
  33. data/lib/tina4/frond.rb +102 -10
  34. data/lib/tina4/health.rb +30 -14
  35. data/lib/tina4/job.rb +15 -5
  36. data/lib/tina4/log.rb +236 -32
  37. data/lib/tina4/mcp.rb +11 -5
  38. data/lib/tina4/messenger.rb +317 -82
  39. data/lib/tina4/metrics.rb +179 -891
  40. data/lib/tina4/middleware.rb +191 -56
  41. data/lib/tina4/migration.rb +17 -1
  42. data/lib/tina4/orm.rb +114 -17
  43. data/lib/tina4/public/css/tina4.min.css +1 -1
  44. data/lib/tina4/queue.rb +154 -9
  45. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  46. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  47. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  48. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  49. data/lib/tina4/rack_app.rb +94 -316
  50. data/lib/tina4/request.rb +48 -8
  51. data/lib/tina4/response.rb +42 -1
  52. data/lib/tina4/response_cache.rb +142 -24
  53. data/lib/tina4/router.rb +141 -12
  54. data/lib/tina4/session.rb +243 -29
  55. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  56. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  57. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  58. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  59. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  61. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  62. data/lib/tina4/shutdown.rb +180 -30
  63. data/lib/tina4/sql_translator.rb +110 -0
  64. data/lib/tina4/swagger.rb +50 -18
  65. data/lib/tina4/version.rb +1 -1
  66. data/lib/tina4/webserver.rb +28 -6
  67. data/lib/tina4.rb +301 -38
  68. metadata +35 -17
  69. data/lib/tina4/scss_compiler.rb +0 -349
@@ -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. A before_* that sets status >= 400 also
55
- # halts (the existing 4xx short-circuit). after_* still run on either
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
- # 1. Block-based before handlers (pattern-matched)
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
- # Support returning [request, response] (Python convention) or false to halt
83
- if result == false
84
- return false
85
- elsif result.is_a?(Array) && result.length == 2
86
- request, response = result
87
- # If response already has a non-2xx status, halt processing
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?(Array) && result.length == 2
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
- # Uses the same config source as CorsMiddleware module.
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
- config = load_config
257
- origin = resolve_origin(request, config)
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
- def load_config
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
- if config[:origins] == "*"
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
@@ -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
- if kind == "ruby" || kind == "python"
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: nil, offset: nil, order_by: nil, include: nil)
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: nil, offset: nil, order_by: nil, include: nil)
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: nil, offset: nil, include: nil)
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: 20, offset: 0)
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
- parts << "PRIMARY KEY" if opts[:primary_key]
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: 20, offset: 0|
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
- self.class.exists(pk_value)
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 = { pk => pk_value }
760
- data.delete(pk)
761
- # Remove mapped primary key too
762
- mapped_pk = self.class.field_mapping[pk.to_s]
763
- data.delete(mapped_pk.to_sym) if mapped_pk
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
- { pk => pk_value }
954
+ pk_filter
858
955
  )
859
956
  else
860
- db.delete(self.class.table_name, { pk => pk_value })
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, { pk => pk_value })
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
- { pk => pk_value }
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}=")