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
  require "json"
3
9
  require "uri"
4
10
  require "digest"
@@ -27,28 +33,49 @@ module Tina4
27
33
  @password = password
28
34
  @drivers = Array.new(pool_size) # nil slots — lazy creation
29
35
  @index = 0
36
+ @owners = {}
30
37
  @mutex = Mutex.new
31
38
  end
32
39
 
33
- # Get the next driver via round-robin. Thread-safe.
40
+ # A lease belongs to one thread until checkin; rotating never exposes a
41
+ # connection with another caller's uncommitted work. Exhaustion is immediate
42
+ # so a synchronous caller cannot deadlock waiting on its own transaction.
34
43
  def checkout
35
44
  @mutex.synchronize do
36
- idx = @index
37
- @index = (@index + 1) % @pool_size
38
-
39
- if @drivers[idx].nil?
40
- driver = @driver_factory.call
41
- driver.connect(@connection_string, username: @username, password: @password)
42
- @drivers[idx] = driver
45
+ @pool_size.times do
46
+ idx = @index
47
+ @index = (@index + 1) % @pool_size
48
+ next if @owners.key?(idx)
49
+ unless @drivers[idx]
50
+ driver = @driver_factory.call
51
+ driver.connect(@connection_string, username: @username, password: @password)
52
+ @drivers[idx] = driver
53
+ end
54
+ @owners[idx] = Thread.current
55
+ return @drivers[idx]
43
56
  end
44
-
45
- @drivers[idx]
57
+ raise "Database connection pool exhausted"
46
58
  end
47
59
  end
48
60
 
49
- # Return a driver to the pool. Currently a no-op for round-robin.
50
- def checkin(_driver)
51
- # no-op
61
+ # Compatibility getter for adapter metadata. It never exposes a leased
62
+ # connection; callers performing driver I/O directly must checkout/checkin.
63
+ def peek
64
+ driver = checkout
65
+ checkin(driver)
66
+ driver
67
+ end
68
+
69
+ def checkin(driver, discard: false)
70
+ @mutex.synchronize do
71
+ idx = @drivers.index { |candidate| candidate.equal?(driver) }
72
+ raise ArgumentError, "Driver is not leased by this thread" unless idx && @owners[idx].equal?(Thread.current)
73
+ if discard
74
+ driver.close rescue nil
75
+ @drivers[idx] = nil
76
+ end
77
+ @owners.delete(idx)
78
+ end
52
79
  end
53
80
 
54
81
  # Close all active connections.
@@ -60,6 +87,7 @@ module Tina4
60
87
  @drivers[i] = nil
61
88
  end
62
89
  end
90
+ @owners.clear
63
91
  end
64
92
  end
65
93
 
@@ -250,6 +278,87 @@ module Tina4
250
278
  TRAILING_LIMIT_RE.match?(scrub_sql_text(sql))
251
279
  end
252
280
 
281
+ # Strip a trailing TOP-LEVEL ORDER BY, for the COUNT probe ONLY.
282
+ #
283
+ # SQL Server rejects an ORDER BY inside a derived table unless TOP/OFFSET/
284
+ # FETCH legalises it (error 1033), so wrapping "... ORDER BY id" in
285
+ # SELECT COUNT(*) FROM (...) failed on MSSQL, the best-effort probe returned
286
+ # nil, and fetch reported the PAGE LENGTH as the total - measured on the lab:
287
+ # 10 for a 10-row page of 22 matching rows. ORDER BY cannot change a count,
288
+ # so the probe drops it; the paginated query keeps it. An ORDER BY nested in
289
+ # a subquery, or one already followed by OFFSET/FETCH/FOR/LIMIT/ROWS, is left
290
+ # alone. Positions are found on the scrubbed text (same length as the
291
+ # original), so an "ORDER BY" inside a literal or comment is never cut.
292
+ # Parity with Python's _strip_trailing_order_by and PHP's
293
+ # SqlNormalizerTrait::stripTrailingOrderBy.
294
+ ORDER_BY_RE = /\bORDER\s+BY\b/i.freeze
295
+ ORDER_TAIL_KEEP_RE = /\b(?:OFFSET|FETCH|FOR|LIMIT|ROWS)\b/i.freeze
296
+
297
+ def self.strip_trailing_order_by(sql)
298
+ return sql if sql.nil? || sql.empty?
299
+
300
+ scrubbed = scrub_sql_text(sql)
301
+ last_top_level = nil
302
+ scrubbed.to_enum(:scan, ORDER_BY_RE).each do
303
+ pos = Regexp.last_match.begin(0)
304
+ last_top_level = pos if top_level_position?(scrubbed, pos)
305
+ end
306
+ return sql if last_top_level.nil?
307
+ return sql if ORDER_TAIL_KEEP_RE.match?(scrubbed[last_top_level..])
308
+
309
+ sql[0...last_top_level].rstrip
310
+ end
311
+
312
+ # True when +pos+ sits outside every bracket of the (scrubbed) statement.
313
+ def self.top_level_position?(scrubbed, pos)
314
+ scrubbed[0...pos].count("(") == scrubbed[0...pos].count(")")
315
+ end
316
+
317
+ # True when the statement WRITES, even though it may also return rows
318
+ # (issue #133): INSERT/UPDATE/DELETE ... RETURNING, MSSQL's OUTPUT, MERGE,
319
+ # or a Postgres data-modifying CTE (WITH x AS (INSERT ...) SELECT ...).
320
+ #
321
+ # THE CROSS-FRAMEWORK CONTRACT (identical in Python, PHP, Node and Ruby):
322
+ # after string literals, quoted identifiers and comments are scrubbed and
323
+ # leading whitespace/brackets stripped, the statement is a write when its
324
+ # FIRST WORD is one of WRITE_VERBS, or when it starts with WITH and its body
325
+ # holds INSERT, UPDATE, DELETE or MERGE. `WHERE note = 'DELETE'` stays a
326
+ # read because the literal is scrubbed first; a locking read
327
+ # (`SELECT ... FOR UPDATE`) starts with SELECT and stays a read. Erring
328
+ # towards "write" is the safe direction: a misread read is merely committed.
329
+ #
330
+ # #fetch / #fetch_one run a write exactly once, as written - no COUNT probe,
331
+ # no pagination, never cached - and commit it like #execute outside an
332
+ # explicit transaction, so the caller is never handed the id of a row that
333
+ # does not persist.
334
+ WRITE_VERBS = %w[INSERT UPDATE DELETE MERGE UPSERT REPLACE].freeze
335
+ CTE_WRITE_RE = /\b(INSERT|UPDATE|DELETE|MERGE)\b/i.freeze
336
+
337
+ def self.write_statement?(sql)
338
+ scrubbed = scrub_sql_text(sql.to_s).sub(/\A[\s(]+/, "")
339
+ verb = scrubbed[/\A[A-Za-z]+/].to_s.upcase
340
+ return true if WRITE_VERBS.include?(verb)
341
+
342
+ verb == "WITH" && CTE_WRITE_RE.match?(scrubbed)
343
+ end
344
+
345
+ # True when the statement produces rows (the #execute contract): after
346
+ # literals and comments are scrubbed, it starts with SELECT, WITH, VALUES,
347
+ # CALL, EXEC or EXECUTE, or it is a write (.write_statement?) carrying
348
+ # RETURNING or MSSQL's OUTPUT. A data-modifying CTE starts with WITH, so it
349
+ # is included. SELECT ... INTO (a table-creating write) is a known edge: it
350
+ # returns an empty row set.
351
+ ROW_LEAD_VERBS = %w[SELECT WITH VALUES CALL EXEC EXECUTE].freeze
352
+ RETURNING_CLAUSE_RE = /\b(?:RETURNING|OUTPUT)\b/i.freeze
353
+
354
+ def self.returns_rows?(sql)
355
+ scrubbed = scrub_sql_text(sql.to_s).sub(/\A[\s(]+/, "")
356
+ verb = scrubbed[/\A[A-Za-z]+/].to_s.upcase
357
+ return true if ROW_LEAD_VERBS.include?(verb)
358
+
359
+ write_statement?(sql) && RETURNING_CLAUSE_RE.match?(scrubbed)
360
+ end
361
+
253
362
  # Static factory — cross-framework consistency: Database.create(url)
254
363
  def self.create(url, username: "", password: "", pool: nil)
255
364
  new(url, username: username.empty? ? nil : username,
@@ -317,6 +426,7 @@ module Tina4
317
426
  # commit/rollback clear it. While pinned, current_driver returns the same
318
427
  # driver for every call so the whole transaction runs on one connection.
319
428
  @tx_pin_key = :"tina4_pinned_adapter_#{object_id}"
429
+ @operation_driver_key = :"tina4_operation_adapter_#{object_id}"
320
430
  # Per-thread nested-transaction depth counter (DB-contract C, v3.13.37).
321
431
  # A second start_transaction on a thread that already holds the pin is a
322
432
  # double-begin: most engines silently commit or no-op the inner BEGIN,
@@ -453,13 +563,15 @@ module Tina4
453
563
  def current_driver
454
564
  pinned = Thread.current[@tx_pin_key]
455
565
  return pinned if pinned
566
+ active = Thread.current[@operation_driver_key]
567
+ return active if active
456
568
 
457
569
  # Fail with the REAL reason, at the point of use. Without this the caller
458
570
  # gets a nil dereference from deep inside the driver and has to guess.
459
571
  raise Tina4::DatabaseConnectionError, connect_error_message if @connect_error
460
572
 
461
573
  if @pool
462
- @pool.checkout
574
+ @pool.peek
463
575
  else
464
576
  @driver
465
577
  end
@@ -582,6 +694,11 @@ module Tina4
582
694
  # subqueries downstream survive a user-supplied semicolon.
583
695
  sql = Tina4::Database.strip_trailing_semicolons(sql)
584
696
 
697
+ # Issue #133: a write that returns rows is a WRITE - run it once, as
698
+ # written (no pagination, no count probe, no cache), and commit it like
699
+ # #execute outside an explicit transaction.
700
+ return fetch_write(drv, sql, params) if Tina4::Database.write_statement?(sql)
701
+
585
702
  effective_sql = sql
586
703
  # Skip appending LIMIT if the statement already ENDS with its own.
587
704
  #
@@ -656,6 +773,10 @@ module Tina4
656
773
  # never populated either. Default `false` preserves cached behaviour.
657
774
  def fetch_one(sql, params = [], no_cache: false)
658
775
  sql = Tina4::Database.strip_trailing_semicolons(sql)
776
+ # Issue #133: INSERT/UPDATE/DELETE ... RETURNING through fetch_one is a
777
+ # write - commit it like #execute (never cached), see .write_statement?.
778
+ return fetch_write(current_driver, sql, params).records.first if Tina4::Database.write_statement?(sql)
779
+
659
780
  if @cache_enabled && !no_cache
660
781
  key = cache_key(sql + ":ONE", params)
661
782
  cached = cache_get(key)
@@ -674,7 +795,25 @@ module Tina4
674
795
  fetch_one_direct(sql, params)
675
796
  end
676
797
 
798
+ # tina4: ADR-0069 - a data or filter-map KEY becomes a bare column name in
799
+ # the SQL these helpers build, so it must be a plain identifier (letters,
800
+ # digits, underscore, dollar; not starting with a digit). Anything else is
801
+ # refused before any SQL. Valid names are emitted exactly as before.
802
+ PLAIN_COLUMN_NAME = /\A[A-Za-z_][A-Za-z0-9_$]*\z/.freeze
803
+
804
+ def check_column_names!(keys)
805
+ keys.each do |key|
806
+ raise ArgumentError, "Invalid column name '#{key}'" unless key.to_s.match?(PLAIN_COLUMN_NAME)
807
+ end
808
+ end
809
+ private :check_column_names!
810
+
677
811
  def insert(table, data)
812
+ if data.is_a?(Array)
813
+ data.each { |row| check_column_names!(row.keys) }
814
+ else
815
+ check_column_names!(data.keys)
816
+ end
678
817
  cache_invalidate if @cache_enabled
679
818
  drv = current_driver
680
819
 
@@ -765,6 +904,7 @@ module Tina4
765
904
  if filter.is_a?(Hash)
766
905
  return ["", []] if filter.empty?
767
906
 
907
+ check_column_names!(filter.keys)
768
908
  drv = current_driver
769
909
  [filter.keys.map { |k| "#{k} = #{drv.placeholder}" }.join(" AND "), filter.values]
770
910
  else
@@ -779,6 +919,7 @@ module Tina4
779
919
  # the WHERE clause. With neither a filter nor a primary key in `data` this
780
920
  # raises rather than overwriting every row (audit feature 4, P1).
781
921
  def update(table, data, filter = {}, params = nil)
922
+ check_column_names!(data.keys)
782
923
  where_sql, where_params = as_where(filter, params)
783
924
  data = data.dup
784
925
 
@@ -850,8 +991,9 @@ module Tina4
850
991
 
851
992
  # Delete rows. A filterless delete raises; use truncate() to empty a table.
852
993
  def delete(table, filter = {}, params = nil)
853
- # List of hashes — delete each row
994
+ # List of hashes — delete each row (every key checked before the first delete)
854
995
  if filter.is_a?(Array)
996
+ filter.each { |row| check_column_names!(row.keys) if row.is_a?(Hash) }
855
997
  total = 0
856
998
  filter.each { |row| total += delete(table, row).affected_rows }
857
999
  return Tina4::DatabaseResult.new([], affected_rows: total, last_id: nil)
@@ -914,8 +1056,14 @@ module Tina4
914
1056
  # into a silent partial-write footgun. This mirrors fetch()/fetch_one(),
915
1057
  # which already raise, and the Python master (database.execute).
916
1058
  #
917
- # On SUCCESS the return is unchanged: a DatabaseResult when the SQL contains
918
- # RETURNING, CALL, EXEC, or SELECT (truthy), otherwise true. Never false.
1059
+ # ROWS: a statement that produces rows - SELECT, WITH ... SELECT, a write
1060
+ # with RETURNING (MSSQL: OUTPUT), CALL/EXEC - returns a Tina4::DatabaseResult
1061
+ # carrying them, the SAME type #fetch returns (cross-framework contract,
1062
+ # matching PHP). It runs exactly once, as written: no COUNT probe, no
1063
+ # LIMIT/OFFSET. A write among them commits like any other write outside an
1064
+ # explicit transaction. This used to hand back the DRIVER's raw result (a
1065
+ # PG::Result, a Mysql2 result, an Fb::Cursor), and `true` for WITH and
1066
+ # OUTPUT. A write that returns no rows still returns true. Never false.
919
1067
  #
920
1068
  # Higher-level callers that promise a boolean (ORM save/create_table) wrap
921
1069
  # this in begin/rescue and return false themselves; the migration runner and
@@ -924,14 +1072,15 @@ module Tina4
924
1072
  def execute(sql, params = [])
925
1073
  cache_invalidate if @cache_enabled
926
1074
  drv = current_driver
927
- result = drv.execute(sql, params)
928
- @last_error = nil
929
- autocommit_standalone_write(drv)
930
- sql_upper = sql.strip.upcase
931
- if sql_upper.include?("RETURNING") || sql_upper.start_with?("CALL ") ||
932
- sql_upper.start_with?("EXEC ") || sql_upper.start_with?("SELECT ")
1075
+ if Tina4::Database.returns_rows?(sql)
1076
+ result = fetch_direct(drv, Tina4::Database.strip_trailing_semicolons(sql), params)
1077
+ autocommit_standalone_write(drv) if Tina4::Database.write_statement?(sql)
933
1078
  return result
934
1079
  end
1080
+
1081
+ drv.execute(sql, params)
1082
+ @last_error = nil
1083
+ autocommit_standalone_write(drv)
935
1084
  true
936
1085
  rescue => e
937
1086
  @last_error = e.message
@@ -1015,18 +1164,12 @@ module Tina4
1015
1164
  end
1016
1165
 
1017
1166
  def transaction
1018
- drv = current_driver
1019
- Thread.current[@tx_pin_key] = drv
1020
- Thread.current[@tx_depth_key] = 1
1021
- drv.begin_transaction
1167
+ start_transaction
1022
1168
  yield self
1023
- drv.commit
1169
+ commit
1024
1170
  rescue => e
1025
- drv.rollback if drv
1171
+ rollback if Thread.current[@tx_pin_key]
1026
1172
  raise e
1027
- ensure
1028
- Thread.current[@tx_pin_key] = nil
1029
- Thread.current[@tx_depth_key] = nil
1030
1173
  end
1031
1174
 
1032
1175
  # Begin a transaction without a block — matches PHP/Python/Node API.
@@ -1054,10 +1197,15 @@ module Tina4
1054
1197
  Thread.current[@tx_depth_key] = depth + 1
1055
1198
  return
1056
1199
  end
1057
- drv = current_driver
1200
+ drv = Thread.current[@operation_driver_key] || (@pool ? @pool.checkout : current_driver)
1058
1201
  Thread.current[@tx_pin_key] = drv
1059
1202
  Thread.current[@tx_depth_key] = 1
1060
- drv.begin_transaction
1203
+ begin
1204
+ drv.begin_transaction
1205
+ rescue
1206
+ release_transaction_driver(discard: true)
1207
+ raise
1208
+ end
1061
1209
  end
1062
1210
 
1063
1211
  # Commit the current transaction and release the driver pin.
@@ -1078,9 +1226,8 @@ module Tina4
1078
1226
  end
1079
1227
  current_driver.commit
1080
1228
  @last_error = nil
1081
- # Success — release the pin.
1082
- Thread.current[@tx_pin_key] = nil
1083
- Thread.current[@tx_depth_key] = nil
1229
+ # Success — release the exclusive transaction lease.
1230
+ release_transaction_driver
1084
1231
  rescue => e
1085
1232
  # Keep the pin so rollback reaches this same connection.
1086
1233
  @last_error = e.message
@@ -1095,14 +1242,15 @@ module Tina4
1095
1242
  # itself raises, @last_error is captured and the error re-raised, but the pin
1096
1243
  # is still released so a poisoned connection doesn't stay pinned forever.
1097
1244
  def rollback
1245
+ failed = false
1098
1246
  current_driver.rollback
1099
1247
  @last_error = nil
1100
1248
  rescue => e
1249
+ failed = true
1101
1250
  @last_error = e.message
1102
1251
  raise
1103
1252
  ensure
1104
- Thread.current[@tx_pin_key] = nil
1105
- Thread.current[@tx_depth_key] = nil
1253
+ release_transaction_driver(discard: failed)
1106
1254
  end
1107
1255
 
1108
1256
  def tables
@@ -1168,12 +1316,14 @@ module Tina4
1168
1316
 
1169
1317
  # Check out a driver from the pool (or return the single driver).
1170
1318
  def checkout
1171
- current_driver
1319
+ @pool ? @pool.checkout : current_driver
1172
1320
  end
1173
1321
 
1174
- # Return a driver to the pool. No-op for round-robin pool or single connection.
1175
- def checkin(_driver)
1176
- # no-op
1322
+ # Explicit borrowers must return their own lease. Transaction leases are
1323
+ # released only by commit/rollback, never by an unrelated checkin.
1324
+ def checkin(driver)
1325
+ raise ArgumentError, "Cannot check in an active transaction" if driver.equal?(Thread.current[@tx_pin_key])
1326
+ @pool&.checkin(driver)
1177
1327
  end
1178
1328
 
1179
1329
  # Close all pooled connections (or the single connection).
@@ -1256,6 +1406,43 @@ module Tina4
1256
1406
  sequence_next(seq_key, table: table, pk_column: pk_column)
1257
1407
  end
1258
1408
 
1409
+ # Keep the entire public operation (including nested metadata/query calls)
1410
+ # on one lease. A transaction may adopt it and keep it beyond this scope.
1411
+ def with_operation_driver
1412
+ return yield unless @pool
1413
+ return yield if Thread.current[@tx_pin_key] || Thread.current[@operation_driver_key]
1414
+ driver = @pool.checkout
1415
+ Thread.current[@operation_driver_key] = driver
1416
+ discard = false
1417
+ begin
1418
+ yield
1419
+ rescue
1420
+ # A failed standalone PostgreSQL statement may leave an aborted
1421
+ # transaction. Reset it before another caller can borrow the driver.
1422
+ unless Thread.current[@tx_pin_key].equal?(driver)
1423
+ begin
1424
+ driver.rollback
1425
+ rescue
1426
+ discard = true
1427
+ end
1428
+ end
1429
+ raise
1430
+ ensure
1431
+ Thread.current[@operation_driver_key] = nil
1432
+ @pool.checkin(driver, discard: discard) unless Thread.current[@tx_pin_key].equal?(driver)
1433
+ end
1434
+ end
1435
+
1436
+ def release_transaction_driver(discard: false)
1437
+ driver = Thread.current[@tx_pin_key]
1438
+ Thread.current[@tx_pin_key] = nil
1439
+ Thread.current[@tx_depth_key] = nil
1440
+ if @pool && driver && !Thread.current[@operation_driver_key].equal?(driver)
1441
+ @pool.checkin(driver, discard: discard)
1442
+ end
1443
+ end
1444
+ private :with_operation_driver, :release_transaction_driver
1445
+
1259
1446
  private
1260
1447
 
1261
1448
  # Run a fetch straight against the driver — no cache lookup or store.
@@ -1296,7 +1483,8 @@ module Tina4
1296
1483
 
1297
1484
  alias_name = driver_implements?(drv, :count_subquery_alias) ? drv.count_subquery_alias.to_s : ""
1298
1485
  suffix = alias_name.empty? ? "" : " AS #{alias_name}"
1299
- rows = drv.execute_query("SELECT COUNT(*) AS tina4_total FROM (#{sql}\n)#{suffix}", params)
1486
+ probe_sql = Tina4::Database.strip_trailing_order_by(sql)
1487
+ rows = drv.execute_query("SELECT COUNT(*) AS tina4_total FROM (#{probe_sql}\n)#{suffix}", params)
1300
1488
  row = rows.is_a?(Array) ? rows.first : nil
1301
1489
  return nil unless row.is_a?(Hash)
1302
1490
 
@@ -1306,6 +1494,24 @@ module Tina4
1306
1494
  nil
1307
1495
  end
1308
1496
 
1497
+ # Issue #133: run a write that returns rows (INSERT/UPDATE/DELETE ...
1498
+ # RETURNING, MSSQL OUTPUT) reached through #fetch / #fetch_one.
1499
+ #
1500
+ # The Python master ended fetch/fetch_one with a ROLLBACK outside a
1501
+ # transaction, so the caller got the id of a row that was then discarded -
1502
+ # silent data loss. Here the write gets exactly what #execute gives it: the
1503
+ # query cache is invalidated (and never populated), the statement runs once
1504
+ # AS WRITTEN on ONE driver (no LIMIT/OFFSET or COUNT probe wrapped round it),
1505
+ # and autocommit_standalone_write commits it on that same driver unless an
1506
+ # explicit transaction owns the connection - inside start_transaction the
1507
+ # caller's commit/rollback still decides.
1508
+ def fetch_write(drv, sql, params)
1509
+ cache_invalidate if @cache_enabled
1510
+ result = fetch_direct(drv, sql, params)
1511
+ autocommit_standalone_write(drv)
1512
+ result
1513
+ end
1514
+
1309
1515
  def fetch_direct(drv, effective_sql, params, limit: 0, offset: 0, total: nil)
1310
1516
  result = drv.execute_query(effective_sql, params)
1311
1517
  @last_error = nil
@@ -1818,4 +2024,14 @@ module Tina4
1818
2024
  raise "Driver #{klass_name} not loaded. Install the required gem."
1819
2025
  end
1820
2026
  end
2027
+ module DatabaseOperationLeases
2028
+ %i[fetch_all fetch fetch_one insert primary_key update delete truncate
2029
+ get_last_id execute tables columns table_exists? get_next_id commit rollback].each do |operation|
2030
+ define_method(operation) do |*args, **kwargs, &block|
2031
+ with_operation_driver { super(*args, **kwargs, &block) }
2032
+ end
2033
+ end
2034
+ end
2035
+ Database.prepend(DatabaseOperationLeases)
2036
+
1821
2037
  end
@@ -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
  # The contract every database driver must satisfy.
@@ -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
  require "json"
3
9
 
4
10
  module Tina4
@@ -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 "uri"
4
10
 
data/lib/tina4/debug.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
  # Backward compatibility: Tina4::Debug is now Tina4::Log
4
10
  require_relative "log"
data/lib/tina4/dev.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
  # Convenience require for all development/optional tools.
4
10
  # Usage: require "tina4/dev"