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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/COMMERCIAL-LICENSE.md +8 -0
- data/LICENSE +373 -0
- data/LICENSE.txt +373 -21
- data/NOTICE +11 -0
- data/README.md +11 -7
- data/exe/tina4ruby +6 -0
- data/lib/tina4/ai.rb +6 -0
- data/lib/tina4/ai_client.rb +10 -3
- data/lib/tina4/api.rb +57 -7
- data/lib/tina4/auth.rb +18 -12
- data/lib/tina4/auto_crud.rb +83 -27
- data/lib/tina4/background.rb +7 -1
- data/lib/tina4/base64.rb +59 -0
- data/lib/tina4/cache.rb +6 -0
- data/lib/tina4/cache_backends/base_backend.rb +6 -0
- data/lib/tina4/cache_backends/database_backend.rb +6 -0
- data/lib/tina4/cache_backends/file_backend.rb +6 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
- data/lib/tina4/cache_backends/memory_backend.rb +6 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
- data/lib/tina4/cache_backends/redis_backend.rb +6 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
- data/lib/tina4/cache_backends.rb +6 -0
- data/lib/tina4/cli.rb +22 -7
- data/lib/tina4/constants.rb +12 -8
- data/lib/tina4/container.rb +6 -0
- data/lib/tina4/context/chunker.rb +6 -0
- data/lib/tina4/context.rb +13 -5
- data/lib/tina4/cors.rb +66 -10
- data/lib/tina4/crud.rb +78 -9
- data/lib/tina4/database/sqlite3_adapter.rb +9 -1
- data/lib/tina4/database.rb +260 -44
- data/lib/tina4/database_adapter.rb +6 -0
- data/lib/tina4/database_result.rb +6 -0
- data/lib/tina4/database_url.rb +6 -0
- data/lib/tina4/debug.rb +6 -0
- data/lib/tina4/dev.rb +6 -0
- data/lib/tina4/dev_admin.rb +215 -89
- data/lib/tina4/dev_mailbox.rb +6 -1
- data/lib/tina4/dispatch_pipeline.rb +86 -1
- data/lib/tina4/docs.rb +6 -0
- data/lib/tina4/docstore.rb +23 -4
- data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
- data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
- data/lib/tina4/drivers/firebird_driver.rb +95 -6
- data/lib/tina4/drivers/mongodb_driver.rb +6 -0
- data/lib/tina4/drivers/mssql_driver.rb +54 -39
- data/lib/tina4/drivers/mysql_driver.rb +6 -0
- data/lib/tina4/drivers/odbc_driver.rb +6 -0
- data/lib/tina4/drivers/postgres_driver.rb +35 -8
- data/lib/tina4/drivers/schema_split.rb +6 -0
- data/lib/tina4/drivers/sqlite_driver.rb +8 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +6 -0
- data/lib/tina4/error_overlay.rb +6 -0
- data/lib/tina4/events.rb +6 -0
- data/lib/tina4/feedback.rb +9 -2
- data/lib/tina4/field_types.rb +6 -0
- data/lib/tina4/form_parser.rb +241 -0
- data/lib/tina4/frond.rb +196 -56
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
- data/lib/tina4/graph.rb +6 -0
- data/lib/tina4/graphql.rb +28 -27
- data/lib/tina4/health.rb +6 -0
- data/lib/tina4/html_element.rb +6 -0
- data/lib/tina4/http_server.rb +690 -0
- data/lib/tina4/imap_client.rb +352 -0
- data/lib/tina4/import_helper.rb +6 -0
- data/lib/tina4/job.rb +6 -0
- data/lib/tina4/localization.rb +6 -0
- data/lib/tina4/log.rb +6 -0
- data/lib/tina4/mail_socket.rb +124 -0
- data/lib/tina4/mcp.rb +15 -8
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +24 -9
- data/lib/tina4/migration.rb +6 -0
- data/lib/tina4/model_collection.rb +6 -0
- data/lib/tina4/mqtt.rb +9 -2
- data/lib/tina4/mqtt_message.rb +6 -0
- data/lib/tina4/orm.rb +72 -16
- data/lib/tina4/parse_json.rb +25 -0
- data/lib/tina4/plan.rb +9 -2
- data/lib/tina4/point.rb +6 -0
- data/lib/tina4/port_takeover.rb +6 -0
- data/lib/tina4/project_index.rb +6 -0
- data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
- data/lib/tina4/public/bundled-component-provenance.json +48 -0
- data/lib/tina4/public/js/tina4js.min.js +13 -3
- data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
- data/lib/tina4/push.rb +9 -3
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
- data/lib/tina4/queue_backends/lite_backend.rb +6 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
- data/lib/tina4/rack_app.rb +11 -1
- data/lib/tina4/rate_limiter.rb +6 -0
- data/lib/tina4/realtime/attachment.rb +6 -0
- data/lib/tina4/realtime/channel.rb +6 -0
- data/lib/tina4/realtime/channel_member.rb +6 -0
- data/lib/tina4/realtime/local_storage.rb +6 -0
- data/lib/tina4/realtime/message.rb +6 -0
- data/lib/tina4/realtime/s3_storage.rb +6 -0
- data/lib/tina4/realtime/storage.rb +6 -0
- data/lib/tina4/realtime/storage_backend.rb +6 -0
- data/lib/tina4/realtime/workspace.rb +6 -0
- data/lib/tina4/realtime.rb +10 -3
- data/lib/tina4/request.rb +85 -127
- data/lib/tina4/response.rb +71 -9
- data/lib/tina4/response_cache.rb +6 -0
- data/lib/tina4/router.rb +6 -0
- data/lib/tina4/secret_file.rb +34 -0
- data/lib/tina4/seeder.rb +9 -3
- data/lib/tina4/service.rb +8 -2
- data/lib/tina4/service_runner.rb +6 -0
- data/lib/tina4/session.rb +6 -0
- data/lib/tina4/session_handlers/database_handler.rb +6 -0
- data/lib/tina4/session_handlers/file_handler.rb +6 -0
- data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
- data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
- data/lib/tina4/session_handlers/redis_handler.rb +6 -0
- data/lib/tina4/session_handlers/resp_client.rb +6 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
- data/lib/tina4/shutdown.rb +17 -14
- data/lib/tina4/smtp_client.rb +222 -0
- data/lib/tina4/sql_translator.rb +93 -0
- data/lib/tina4/sqlite3_gem.rb +27 -0
- data/lib/tina4/sso.rb +25 -8
- data/lib/tina4/swagger.rb +6 -0
- data/lib/tina4/template.rb +6 -0
- data/lib/tina4/test.rb +8 -2
- data/lib/tina4/test_client.rb +8 -1
- data/lib/tina4/testing.rb +8 -1
- data/lib/tina4/validator.rb +6 -0
- data/lib/tina4/version.rb +7 -1
- data/lib/tina4/webserver.rb +25 -143
- data/lib/tina4/websocket.rb +10 -4
- data/lib/tina4/websocket_backplane.rb +19 -2
- data/lib/tina4/wsdl.rb +31 -16
- data/lib/tina4/xml_parser.rb +259 -0
- data/lib/tina4.rb +87 -31
- data/lib/tina4ruby.rb +6 -0
- metadata +43 -140
data/lib/tina4/database.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
|
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
|
-
#
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
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
|
-
#
|
|
918
|
-
# RETURNING, CALL
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1169
|
+
commit
|
|
1024
1170
|
rescue => e
|
|
1025
|
-
|
|
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
|
-
|
|
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
|
|
1082
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
-
|
|
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
|
data/lib/tina4/database_url.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
|
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"
|