tina4ruby 3.13.137 → 3.13.138

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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +57 -7
  12. data/lib/tina4/auth.rb +18 -12
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +6 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +6 -0
  26. data/lib/tina4/cli.rb +22 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +215 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +8 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +6 -0
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +196 -56
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +28 -27
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +6 -0
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +15 -8
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +24 -9
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +9 -3
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +6 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
  112. data/lib/tina4/rack_app.rb +11 -1
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +6 -0
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +85 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +6 -0
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +25 -8
  146. data/lib/tina4/swagger.rb +6 -0
  147. data/lib/tina4/template.rb +6 -0
  148. data/lib/tina4/test.rb +8 -2
  149. data/lib/tina4/test_client.rb +8 -1
  150. data/lib/tina4/testing.rb +8 -1
  151. data/lib/tina4/validator.rb +6 -0
  152. data/lib/tina4/version.rb +7 -1
  153. data/lib/tina4/webserver.rb +25 -143
  154. data/lib/tina4/websocket.rb +10 -4
  155. data/lib/tina4/websocket_backplane.rb +19 -2
  156. data/lib/tina4/wsdl.rb +31 -16
  157. data/lib/tina4/xml_parser.rb +259 -0
  158. data/lib/tina4.rb +87 -31
  159. data/lib/tina4ruby.rb +6 -0
  160. metadata +43 -140
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "zlib"
4
10
  require "stringio"
@@ -77,13 +83,24 @@ module Tina4
77
83
  # Content-Length reflects the (possibly compressed) body the equivalent
78
84
  # GET would have sent, mirroring the Python master's build_headers() ->
79
85
  # app() ordering (compress -> tag -> 304-decide -> strip-for-HEAD).
86
+ #
87
+ # `security_headers` is here for the same reason: a refusal (a CSRF 403, a
88
+ # legacy auth_handler 403, a pre-match middleware's 429), a 404 and a
89
+ # static file are all produced OUTSIDE the post-match middleware pass that
90
+ # runs SecurityHeadersMiddleware, so they left without CSP, nosniff and
91
+ # X-Frame-Options (python #137, php CSRF-403 parity).
80
92
  ALWAYS_STAGES = %i[
81
93
  compress_and_tag
82
94
  conditional_get
83
95
  head_strip
84
96
  apply_cors
97
+ security_headers
85
98
  ].freeze
86
99
 
100
+ # The three headers whose presence shows SecurityHeadersMiddleware already
101
+ # ran for this response (lower-case: how the pipeline stores them).
102
+ SECURITY_HEADER_PROBE = %w[content-security-policy x-content-type-options x-frame-options].freeze
103
+
87
104
  RESPONSE_STAGES = %i[
88
105
  dev_inspector_capture
89
106
  request_log
@@ -130,7 +147,7 @@ module Tina4
130
147
  DispatchContext = Struct.new(
131
148
  :env, :method, :path, :started_at,
132
149
  :pre_request, :pre_response, :matched_pattern, :matched,
133
- :bypass_response_stages,
150
+ :bypass_response_stages, :security_exempt,
134
151
  keyword_init: true
135
152
  )
136
153
 
@@ -172,11 +189,22 @@ module Tina4
172
189
  # WebSocket upgrade - match against registered ws_routes.
173
190
  def websocket_upgrade(ctx)
174
191
  return nil unless websocket_upgrade?(ctx.env)
192
+ # The AI/test port never holds a live-reload socket: fall through to
193
+ # dev_routes, which answers "Not available on AI port" (Python's built-in
194
+ # server refuses the same upgrade with 404).
195
+ return nil if ctx.path == "/__dev_reload" && ctx.env["tina4.ai_port"]
196
+
197
+ # ADR-0082: the dev reload socket answers only a loopback / TINA4_HOST
198
+ # Host - the same rule the /__dev HTTP gate applies (DNS rebinding).
199
+ if ctx.path.start_with?("/__dev") && !Tina4::DevAdmin.dev_host_allowed?(ctx.env)
200
+ return [403, { "content-type" => "text/plain" }, ["Forbidden: host not allowed"]]
201
+ end
175
202
 
176
203
  ws_result = Tina4::Router.find_ws_route(ctx.path)
177
204
  return nil unless ws_result
178
205
 
179
206
  ws_route, ws_params = ws_result
207
+ ctx.security_exempt = true # a protocol switch, not a document
180
208
  handle_websocket_upgrade(ctx.env, ws_route, ws_params)
181
209
  end
182
210
 
@@ -190,6 +218,9 @@ module Tina4
190
218
  return [404, { "content-type" => "text/plain" }, ["Not available on AI port"]]
191
219
  end
192
220
 
221
+ # The dev dashboard is a debug-only surface with its own inline scripts;
222
+ # it keeps its historical headers.
223
+ ctx.security_exempt = true
193
224
  Tina4::DevAdmin.handle_request(ctx.env)
194
225
  end
195
226
 
@@ -284,11 +315,15 @@ module Tina4
284
315
  # 9110 s9.3.2. Recorded as a finding and fixed separately with its own test
285
316
  # pair - NOT silently inside this extraction.
286
317
  def not_found(ctx)
318
+ # The swagger UI loads its assets from TINA4_SWAGGER_UI_CDN, which the
319
+ # default CSP (default-src 'self') would block, so it stays exempt.
287
320
  if ctx.path == "/swagger" || ctx.path == "/swagger/"
288
321
  ctx.bypass_response_stages = true
322
+ ctx.security_exempt = true
289
323
  return serve_swagger_ui
290
324
  elsif ctx.path == "/swagger/openapi.json"
291
325
  ctx.bypass_response_stages = true
326
+ ctx.security_exempt = true
292
327
  return serve_openapi_json
293
328
  end
294
329
 
@@ -301,6 +336,56 @@ module Tina4
301
336
  handle_404(ctx.path, ctx.env["HTTP_ACCEPT"])
302
337
  end
303
338
 
339
+ # ALWAYS stage: every response gets the security headers a route response
340
+ # gets, whichever stage produced it. See ALWAYS_STAGES.
341
+ def security_headers(ctx, response)
342
+ return nil if ctx.security_exempt
343
+
344
+ with_security_headers(ctx, response)
345
+ end
346
+
347
+ # Issue #137 (parity with tina4-python #137): a static file gets the SAME
348
+ # security headers a route response gets.
349
+ #
350
+ # SecurityHeadersMiddleware is post-match global middleware, so it only ever
351
+ # ran for a MATCHED route; a file from public/ or src/public/ answers from
352
+ # this not-found fallback and skipped it. The same HTML carried CSP, nosniff
353
+ # and X-Frame-Options from a route and none of them as a file - and since
354
+ # "/" resolves to index.html, an SPA's front door was frameable
355
+ # (clickjacking) and ran without a CSP.
356
+ #
357
+ # Only this middleware is applied, and only when it is ATTACHED (which
358
+ # Tina4.initialize! does by default), so removing it still opts a static
359
+ # file out too. The headers come from the middleware's own before hook, so
360
+ # the TINA4_CSP / TINA4_FRAME_OPTIONS / TINA4_HSTS rules stay in one place.
361
+ # Other global middleware (auth, CSRF, rate limits) is deliberately NOT run
362
+ # here: static serving has never been subject to it. A header the file
363
+ # response already set is never overwritten.
364
+ def with_security_headers(ctx, triple)
365
+ return nil unless Tina4::Middleware.global_middleware.include?(Tina4::SecurityHeadersMiddleware)
366
+
367
+ status, headers, body = triple
368
+ # Header names are compared case-insensitively: a route response carries
369
+ # them as the middleware spelled them ("X-Frame-Options").
370
+ present = headers.keys.map { |name| name.to_s.downcase }
371
+ # A matched route already ran the middleware's hook.
372
+ return nil if SECURITY_HEADER_PROBE.all? { |name| present.include?(name) }
373
+
374
+ probe = Tina4::Response.new
375
+ defaults = probe.headers.keys
376
+ request = ctx.pre_request || Tina4::Request.new(ctx.env)
377
+ Tina4::SecurityHeadersMiddleware.before_security(request, probe)
378
+
379
+ merged = headers.dup
380
+ probe.headers.each do |name, value|
381
+ next if defaults.include?(name)
382
+
383
+ key = name.to_s.downcase
384
+ merged[key] = value unless present.include?(key)
385
+ end
386
+ [status, merged, body]
387
+ end
388
+
304
389
  # ── ALWAYS STAGES (compression / ETag / conditional-GET) ──────────
305
390
  # Each returns a replacement Rack triple, or nil to leave it unchanged.
306
391
 
data/lib/tina4/docs.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  # Tina4::Docs — Live API RAG.
4
10
  #
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  # Tina4 DocStore - pymongo-style document storage with a zero-config SQLite fallback.
4
10
  #
@@ -47,6 +53,7 @@ require "json"
47
53
  require "time"
48
54
  require "securerandom"
49
55
  require "delegate"
56
+ require_relative "sqlite3_gem"
50
57
 
51
58
  module Tina4
52
59
  module DocStore
@@ -190,12 +197,24 @@ module Tina4
190
197
 
191
198
  COMPARATORS = { "$gt" => ">", "$gte" => ">=", "$lt" => "<", "$lte" => "<=" }.freeze
192
199
 
200
+ FIELD_SEGMENT_RE = /\A[A-Za-z0-9_-]+\z/.freeze
201
+
193
202
  # Field name -> a JSON path. Dotted names address nested keys.
203
+ #
204
+ # tina4: ADR-0069 - this is the single chokepoint every field path passes
205
+ # through on its way into SQL (extract / json_type / json_each, so filters
206
+ # at any depth, operator fields and sort keys). Every dot-separated segment
207
+ # must match [A-Za-z0-9_-]+ or the path is rejected before any SQL is built.
194
208
  def json_path(field)
195
- segments = field.to_s.split(".").map do |s|
196
- s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) ? s : "\"#{s}\""
209
+ path = field.to_s
210
+ segments = path.split(".", -1)
211
+ if segments.empty? || !segments.all? { |segment| segment.match?(FIELD_SEGMENT_RE) }
212
+ raise ArgumentError,
213
+ "DocStore: invalid field path '#{path}' - each dot-separated segment must match [A-Za-z0-9_-]+"
197
214
  end
198
- "$." + segments.join(".")
215
+
216
+ quoted = segments.map { |segment| segment.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) ? segment : "\"#{segment}\"" }
217
+ "$." + quoted.join(".")
199
218
  end
200
219
 
201
220
  def extract(field) = "json_extract(doc, '#{json_path(field)}')"
@@ -813,7 +832,7 @@ module Tina4
813
832
 
814
833
  def initialize(path = nil)
815
834
  @path = path || ENV["TINA4_DOC_STORE_PATH"] || "data/tina4_docstore.db"
816
- require "sqlite3"
835
+ Tina4.require_sqlite3!
817
836
  if @path != ":memory:"
818
837
  dir = File.dirname(@path)
819
838
  require "fileutils"
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  # ArangoDB graph adapter — the document/AQL engine behind the same surface.
4
10
  #
@@ -18,6 +24,7 @@
18
24
  require "net/http"
19
25
  require "json"
20
26
  require "uri"
27
+ require_relative "../parse_json"
21
28
 
22
29
  module Tina4
23
30
  module Drivers
@@ -221,7 +228,7 @@ module Tina4
221
228
  end
222
229
 
223
230
  def parse_response(response)
224
- parsed = response.body && !response.body.empty? ? JSON.parse(response.body) : {}
231
+ parsed = response.body && !response.body.empty? ? Tina4.parse_json(response.body) : {}
225
232
  code = response.code.to_i
226
233
  if code >= 400 || parsed["error"] == true
227
234
  message = parsed["errorMessage"] || parsed["error"] || "HTTP #{response.code}"
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  # Bolt graph adapter — Neo4j AND Memgraph (both speak Bolt + Cypher).
4
10
  #
@@ -1,3 +1,9 @@
1
+ # Copyright (c) 2026 Code Infinity
2
+ # SPDX-License-Identifier: MPL-2.0
3
+ # This Source Code Form is subject to the terms of the Mozilla Public
4
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+
1
7
  require "set"
2
8
  # frozen_string_literal: true
3
9
 
@@ -7,6 +13,15 @@ module Tina4
7
13
  include Tina4::DatabaseAdapter
8
14
  attr_reader :connection
9
15
 
16
+ # Raised when a statement that returns values from a WRITE
17
+ # (INSERT/UPDATE/DELETE ... RETURNING, or EXECUTE PROCEDURE with outputs)
18
+ # is read through fetch/fetch_one. See #execute_query.
19
+ class ReturningNotSupported < StandardError; end
20
+
21
+ # The `fb` gem's error for a fetch from a cursor Firebird never opened
22
+ # (SQLCODE -504) - what a singleton RETURNING result produces.
23
+ CURSOR_NOT_OPEN_MARKERS = ["cursor is not open", "unknown cursor"].freeze
24
+
10
25
  # Substring markers (lowercased) that identify a dead-socket Firebird
11
26
  # error worth reconnecting for. Idle Firebird connections die silently
12
27
  # behind NAT timeouts, server-side ConnectionIdleTimeout, or Docker
@@ -219,14 +234,45 @@ module Tina4
219
234
  @connection&.close
220
235
  end
221
236
 
237
+ # Run a statement that returns rows and hydrate them.
238
+ #
239
+ # The cursor is driven HERE rather than through Connection#query so that a
240
+ # failed fetch can be cleaned up. Connection#query (and #execute) start an
241
+ # AUTOMATIC transaction when none is open and end it only when the cursor
242
+ # is closed after a SUCCESSFUL fetch - and closing it COMMITS. A fetch that
243
+ # raised used to skip all of that: the automatic transaction, holding any
244
+ # write the statement made, stayed open on the connection, every later
245
+ # statement ran inside it, and DDL on the table blocked ("object in use").
246
+ #
247
+ # The one statement that always fails that way is a write that returns
248
+ # values - INSERT/UPDATE/DELETE ... RETURNING. Firebird runs it as an
249
+ # EXECUTE PROCEDURE-type statement whose values come back only through
250
+ # isc_dsql_execute2's OUTPUT sqlda, which the `fb` gem never passes: it
251
+ # opens no cursor and the first fetch raises "Cursor is not open" (-504).
252
+ # There is no other call in the gem that returns them (measured, fb 0.10.0
253
+ # on Firebird 5), so it is refused with a clear ReturningNotSupported.
254
+ #
255
+ # On ANY fetch failure: outside an explicit transaction the automatic one
256
+ # is ROLLED BACK first (nothing is written, nothing stays locked, and the
257
+ # cursor's close can no longer commit it); inside an explicit transaction
258
+ # it is left for the caller's own rollback. The cursor is then dropped so
259
+ # its prepared statement does not hold the table.
222
260
  def execute_query(sql, params = [])
223
- rows = with_reconnect do
224
- if params.empty?
225
- @connection.query(:hash, sql)
226
- else
227
- @connection.query(:hash, sql, *params)
228
- end
261
+ cursor = with_reconnect do
262
+ params.empty? ? @connection.execute(sql) : @connection.execute(sql, *params)
263
+ end
264
+ # A statement with no result set: execute already ran (and, outside a
265
+ # transaction, committed) it and returned a count.
266
+ return [] unless cursor.respond_to?(:fetchall)
267
+
268
+ rows = begin
269
+ cursor.fetchall(:hash)
270
+ rescue StandardError => e
271
+ abandon_cursor(cursor)
272
+ raise returning_not_supported if cursor_not_open?(e)
273
+ raise
229
274
  end
275
+ cursor.close
230
276
  rows.map { |row| decode_blobs(symbolize_keys(row)) }
231
277
  end
232
278
 
@@ -438,6 +484,49 @@ module Tina4
438
484
  yield
439
485
  end
440
486
 
487
+ # Clean up after a fetch that raised: roll back the AUTOMATIC transaction
488
+ # (never an explicit one - that is the caller's) BEFORE the cursor is
489
+ # released, then free the cursor's prepared statement.
490
+ #
491
+ # Cursor#close, not #drop: #drop raises on the DSQL_close of a cursor
492
+ # Firebird never opened and so never reaches DSQL_drop, leaving the
493
+ # statement - and the table - held until GC ("object in use" on DDL).
494
+ # #close only warns on that step and always drops. It commits only the
495
+ # cursor's OWN automatic transaction, which is already rolled back here
496
+ # (the handle no longer matches), and never an explicit one.
497
+ def abandon_cursor(cursor)
498
+ begin
499
+ @connection.rollback if !@in_transaction && @connection.transaction_started
500
+ rescue StandardError
501
+ nil # the original error is the one worth reporting
502
+ end
503
+ begin
504
+ cursor.close
505
+ rescue StandardError
506
+ nil
507
+ end
508
+ end
509
+
510
+ def cursor_not_open?(error)
511
+ message = error.message.to_s.downcase
512
+ CURSOR_NOT_OPEN_MARKERS.any? { |marker| message.include?(marker) }
513
+ end
514
+
515
+ def returning_not_supported
516
+ outcome = if @in_transaction
517
+ "It ran inside your explicit transaction, which is still open: roll it back."
518
+ else
519
+ "The statement was rolled back: nothing was written and no transaction is left open."
520
+ end
521
+ ReturningNotSupported.new(
522
+ "Firebird: a write that returns values (INSERT/UPDATE/DELETE ... RETURNING, or " \
523
+ "EXECUTE PROCEDURE with outputs) cannot be read back through the `fb` gem - it never " \
524
+ "opens a cursor for a singleton result. #{outcome} Run the write without RETURNING " \
525
+ "and read the row back with a SELECT, use db.insert(...), or write it as " \
526
+ "INSERT ... SELECT ... RETURNING, which Firebird returns as a real cursor."
527
+ )
528
+ end
529
+
441
530
  # Hydrate a raw fb-gem row into a SYMBOL-keyed Hash — parity with the
442
531
  # sqlite/postgres/mssql drivers (SqliteDriver#symbolize_rows,
443
532
  # PostgresDriver#symbolize_result, MssqlDriver's `symbolize_keys: true`),
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  module Drivers
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require_relative "schema_split"
4
10
 
@@ -225,49 +231,58 @@ module Tina4
225
231
  end
226
232
  end
227
233
 
234
+ # tiny_tds has no parameter binding, so each PLACEHOLDER `?` is replaced by
235
+ # its escaped value in ONE pass over the SQL (python #138). This used to
236
+ # `sub` the first `?` once per param, so a `?` inside a string literal, a
237
+ # [bracketed] or "quoted" identifier or a comment took a value, and so did
238
+ # a `?` inside a value spliced in by the previous param ("a?b" then 2 gave
239
+ # 'a2b' and left a placeholder behind). A `?` beyond the params is left as
240
+ # it was.
228
241
  def interpolate_params(sql, params)
229
242
  return sql if params.empty?
230
- result = sql.dup
231
- params.each do |param|
232
- escaped =
233
- if param.nil?
234
- "NULL"
235
- elsif param == true
236
- # SQL Server has no boolean literal — BIT stores 0/1. A raw `true`
237
- # would interpolate as the bareword `true` ("Invalid column name
238
- # 'true'"). Coerce at the bind boundary, parity with the SQLite
239
- # driver's coerce_params and the Python/PHP/Node bind contract.
240
- "1"
241
- elsif param == false
242
- "0"
243
- elsif param.is_a?(Time) || param.is_a?(DateTime)
244
- "'#{(param.respond_to?(:iso8601) ? param.iso8601 : param.to_s).gsub("'", "''")}'"
245
- elsif param.is_a?(String)
246
- interpolate_string(param)
247
- elsif param.is_a?(Integer)
248
- param.to_s
249
- elsif param.is_a?(Float)
250
- # A finite Float is a valid numeric literal; NaN/Infinity are not
251
- # representable in T-SQL and would stringify to a bareword.
252
- raise ArgumentError, "MssqlDriver cannot bind a non-finite Float (#{param})" unless param.finite?
253
243
 
254
- param.to_s
255
- elsif param.is_a?(Numeric)
256
- # BigDecimal / Rational -> a plain decimal literal.
257
- param.to_s
258
- else
259
- # MSSQL-INTERP-RUBY: the old `else param.to_s` emitted a BAREWORD for
260
- # any unrecognised type (a Symbol became `WHERE x = active`, invalid
261
- # or unintended SQL - a breakage / injection vector). Refuse it loudly
262
- # instead of splicing an arbitrary object's #to_s into the statement.
263
- raise ArgumentError,
264
- "MssqlDriver cannot safely bind a #{param.class} parameter to MSSQL " \
265
- "(#{param.inspect}); pass nil, true/false, a Time, a String " \
266
- "(text, or ASCII-8BIT bytes), or a Numeric - never a bareword"
267
- end
268
- result = result.sub("?", escaped)
244
+ Tina4::SQLTranslator.replace_placeholders(sql, brackets: true) do |index|
245
+ index < params.length ? sql_literal(params[index]) : "?"
246
+ end
247
+ end
248
+
249
+ # One parameter as a T-SQL literal.
250
+ def sql_literal(param)
251
+ if param.nil?
252
+ "NULL"
253
+ elsif param == true
254
+ # SQL Server has no boolean literal — BIT stores 0/1. A raw `true`
255
+ # would interpolate as the bareword `true` ("Invalid column name
256
+ # 'true'"). Coerce at the bind boundary, parity with the SQLite
257
+ # driver's coerce_params and the Python/PHP/Node bind contract.
258
+ "1"
259
+ elsif param == false
260
+ "0"
261
+ elsif param.is_a?(Time) || param.is_a?(DateTime)
262
+ "'#{(param.respond_to?(:iso8601) ? param.iso8601 : param.to_s).gsub("'", "''")}'"
263
+ elsif param.is_a?(String)
264
+ interpolate_string(param)
265
+ elsif param.is_a?(Integer)
266
+ param.to_s
267
+ elsif param.is_a?(Float)
268
+ # A finite Float is a valid numeric literal; NaN/Infinity are not
269
+ # representable in T-SQL and would stringify to a bareword.
270
+ raise ArgumentError, "MssqlDriver cannot bind a non-finite Float (#{param})" unless param.finite?
271
+
272
+ param.to_s
273
+ elsif param.is_a?(Numeric)
274
+ # BigDecimal / Rational -> a plain decimal literal.
275
+ param.to_s
276
+ else
277
+ # MSSQL-INTERP-RUBY: the old `else param.to_s` emitted a BAREWORD for
278
+ # any unrecognised type (a Symbol became `WHERE x = active`, invalid
279
+ # or unintended SQL - a breakage / injection vector). Refuse it loudly
280
+ # instead of splicing an arbitrary object's #to_s into the statement.
281
+ raise ArgumentError,
282
+ "MssqlDriver cannot safely bind a #{param.class} parameter to MSSQL " \
283
+ "(#{param.inspect}); pass nil, true/false, a Time, a String " \
284
+ "(text, or ASCII-8BIT bytes), or a Numeric - never a bareword"
269
285
  end
270
- result
271
286
  end
272
287
 
273
288
  # Bind a String parameter. ASCII-8BIT (binary) bytes become a T-SQL
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require_relative "schema_split"
4
10
  require_relative "../sql_translator"
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  module Drivers
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require_relative "schema_split"
4
10
 
@@ -59,11 +65,10 @@ module Tina4
59
65
  end
60
66
 
61
67
  def execute_query(sql, params = [])
62
- converted_sql = convert_placeholders(sql)
63
68
  result = if params.empty?
64
- @connection.exec(converted_sql)
69
+ @connection.exec(sql) # no params: sent exactly as written
65
70
  else
66
- @connection.exec_params(converted_sql, params)
71
+ @connection.exec_params(convert_placeholders(sql), params)
67
72
  end
68
73
  track_affected(result)
69
74
  symbolize_result(result)
@@ -77,11 +82,10 @@ module Tina4
77
82
  # Clear the cache so last_insert_id falls back to the lastval() probe,
78
83
  # which is the correct source for a sequence-backed bare INSERT.
79
84
  @last_returning_id = nil if sql.lstrip[0, 6].upcase == "INSERT"
80
- converted_sql = convert_placeholders(sql)
81
85
  result = if params.empty?
82
- @connection.exec(converted_sql)
86
+ @connection.exec(sql) # no params: sent exactly as written
83
87
  else
84
- @connection.exec_params(converted_sql, params)
88
+ @connection.exec_params(convert_placeholders(sql), params)
85
89
  end
86
90
  track_affected(result)
87
91
  result
@@ -127,6 +131,7 @@ module Tina4
127
131
  result = execute_query(sql, data.values)
128
132
  row = result.is_a?(Array) ? result.first : nil
129
133
  id = normalize_returned_id(row)
134
+ id = sequence_value_in(row) if id.nil? && row.is_a?(Hash)
130
135
  # Remember the real id so a follow-up #last_insert_id / db.get_last_id
131
136
  # surfaces THIS value (incl. a UUID string) instead of re-probing
132
137
  # lastval(), which has no sequence for a UUID PK and would return a
@@ -326,6 +331,18 @@ module Tina4
326
331
  end
327
332
  end
328
333
 
334
+ # A key column not named ``id`` (``person_id SERIAL``) is invisible to
335
+ # row_id_value, so ask the sequence. lastval() is session-wide: on a table
336
+ # with no sequence it is a value from some EARLIER insert, so it is only
337
+ # trusted when the row this INSERT returned actually holds it.
338
+ def sequence_value_in(row)
339
+ @last_returning_id = nil
340
+ value = last_insert_id
341
+ return nil if value.nil?
342
+
343
+ row.values.any? { |column_value| column_value.to_s == value.to_s } ? value : nil
344
+ end
345
+
329
346
  # Read the ``id`` column from a RETURNING row regardless of key style
330
347
  # (symbol/string, any case) — execute_query symbolizes keys, but stay
331
348
  # tolerant.
@@ -384,9 +401,19 @@ module Tina4
384
401
  end
385
402
  end
386
403
 
404
+ # ? -> $1, $2 ... for every PLACEHOLDER only: a `?` inside a string
405
+ # literal (incl. E'...' and $$...$$), a quoted identifier or a comment is
406
+ # left alone (python #138). This was a plain gsub, so
407
+ # SELECT 'why?', ? became SELECT 'why$1', $2 and failed.
408
+ #
409
+ # Called ONLY when there are parameters to bind. SQL with no parameters is
410
+ # sent exactly as written (the cross-framework contract), so the jsonb
411
+ # operators ?, ?| and ?& work in a parameterless query.
412
+ # tina4: with parameters, use jsonb_exists(), jsonb_exists_any() or
413
+ # jsonb_exists_all() instead of ?, ?| or ?& - here every bare `?` is a
414
+ # placeholder.
387
415
  def convert_placeholders(sql)
388
- counter = 0
389
- sql.gsub("?") { counter += 1; "$#{counter}" }
416
+ Tina4::SQLTranslator.replace_placeholders(sql) { |index| "$#{index + 1}" }
390
417
  end
391
418
 
392
419
  # Hydrate a PG::Result into an array of symbol-keyed row hashes.
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  module Drivers
@@ -1,6 +1,13 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require_relative "schema_split"
10
+ require_relative "../sqlite3_gem"
4
11
 
5
12
  module Tina4
6
13
  module Drivers
@@ -27,7 +34,7 @@ module Tina4
27
34
  # hang. Every other driver's bound lives in its own connect; this is the
28
35
  # one that has nothing to bind.
29
36
  def connect(connection_string, username: nil, password: nil)
30
- require "sqlite3"
37
+ Tina4.require_sqlite3!
31
38
  db_path = self.class.resolve_path(connection_string)
32
39
 
33
40
  @connection = SQLite3::Database.new(db_path)
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  # Ultipa graph adapter — the portable core built in GQL over tina4-ultipa.
4
10
  #