tina4ruby 3.13.93 → 3.13.96
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +35 -43
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +458 -48
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dev_mailbox.rb +5 -1
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +25 -3
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/frond.rb +102 -10
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +317 -82
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +243 -29
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +301 -38
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/database.rb
CHANGED
|
@@ -5,6 +5,15 @@ require "digest"
|
|
|
5
5
|
require "weakref"
|
|
6
6
|
|
|
7
7
|
module Tina4
|
|
8
|
+
# Raised at the first USE of a database whose connect failed.
|
|
9
|
+
#
|
|
10
|
+
# Connecting is deliberately non-fatal at boot (log loud, then degrade - the same
|
|
11
|
+
# policy the session backends follow), so the failure has to resurface somewhere.
|
|
12
|
+
# It used to resurface as a nil dereference inside the driver
|
|
13
|
+
# ("NoMethodError: private method `exec' called for nil:NilClass"), which named
|
|
14
|
+
# neither the database nor the reason. This carries both.
|
|
15
|
+
class DatabaseConnectionError < StandardError; end
|
|
16
|
+
|
|
8
17
|
# Thread-safe connection pool with round-robin rotation.
|
|
9
18
|
# Connections are created lazily on first use.
|
|
10
19
|
class ConnectionPool
|
|
@@ -136,6 +145,111 @@ module Tina4
|
|
|
136
145
|
stripped
|
|
137
146
|
end
|
|
138
147
|
|
|
148
|
+
# Blank out string literals, quoted identifiers and BOTH comment forms so a
|
|
149
|
+
# keyword search sees only real SQL.
|
|
150
|
+
#
|
|
151
|
+
# Blanks are spaces of the SAME LENGTH (newlines preserved), so offsets and
|
|
152
|
+
# line structure still line up with the original — the scrubbed copy is only
|
|
153
|
+
# ever used to LOOK at the SQL, never to execute it.
|
|
154
|
+
#
|
|
155
|
+
# MEASURED 2026-08-01 on a real 150-row SQLite table with the 100-row cap in
|
|
156
|
+
# force. The old detector was
|
|
157
|
+
# `sql.upcase.split("--")[0].include?("LIMIT")`
|
|
158
|
+
# and each of these returned ALL 150 ROWS instead of 100:
|
|
159
|
+
#
|
|
160
|
+
# SELECT * FROM t WHERE label != 'LIMIT' ORDER BY id literal
|
|
161
|
+
# SELECT * FROM t ORDER BY id -- LIMIT 5 line comment
|
|
162
|
+
# SELECT * FROM t ORDER BY id /* LIMIT 5 */ block comment
|
|
163
|
+
# SELECT id, label AS rate_limit FROM t identifier
|
|
164
|
+
#
|
|
165
|
+
# A silently uncapped read of a whole table is the production incident the
|
|
166
|
+
# cap exists to prevent, and an ordinary column name was enough to cause it.
|
|
167
|
+
# Ported from the Python/Node master (same design, same answers).
|
|
168
|
+
def self.scrub_sql_text(sql)
|
|
169
|
+
return sql if sql.nil? || sql.empty?
|
|
170
|
+
|
|
171
|
+
out = +""
|
|
172
|
+
i = 0
|
|
173
|
+
n = sql.length
|
|
174
|
+
while i < n
|
|
175
|
+
ch = sql[i]
|
|
176
|
+
nxt = i + 1 < n ? sql[i + 1] : ""
|
|
177
|
+
|
|
178
|
+
if ch == "'" || ch == '"'
|
|
179
|
+
quote = ch
|
|
180
|
+
out << " "
|
|
181
|
+
i += 1
|
|
182
|
+
while i < n
|
|
183
|
+
if sql[i] == quote
|
|
184
|
+
# A doubled quote ('' / "") is an ESCAPED quote inside the
|
|
185
|
+
# literal, not its end — blank both and keep going.
|
|
186
|
+
if i + 1 < n && sql[i + 1] == quote
|
|
187
|
+
out << " "
|
|
188
|
+
i += 2
|
|
189
|
+
next
|
|
190
|
+
end
|
|
191
|
+
out << " "
|
|
192
|
+
i += 1
|
|
193
|
+
break
|
|
194
|
+
end
|
|
195
|
+
out << (sql[i] == "\n" ? "\n" : " ")
|
|
196
|
+
i += 1
|
|
197
|
+
end
|
|
198
|
+
next
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if ch == "-" && nxt == "-"
|
|
202
|
+
while i < n && sql[i] != "\n"
|
|
203
|
+
out << " "
|
|
204
|
+
i += 1
|
|
205
|
+
end
|
|
206
|
+
next
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
if ch == "/" && nxt == "*"
|
|
210
|
+
out << " "
|
|
211
|
+
i += 2
|
|
212
|
+
while i < n && !(sql[i] == "*" && i + 1 < n && sql[i + 1] == "/")
|
|
213
|
+
out << (sql[i] == "\n" ? "\n" : " ")
|
|
214
|
+
i += 1
|
|
215
|
+
end
|
|
216
|
+
if i < n
|
|
217
|
+
out << " "
|
|
218
|
+
i += 2
|
|
219
|
+
end
|
|
220
|
+
next
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
out << ch
|
|
224
|
+
i += 1
|
|
225
|
+
end
|
|
226
|
+
out
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# True when the statement ENDS with its own LIMIT clause.
|
|
230
|
+
#
|
|
231
|
+
# Anchored to the END on purpose: a bare "contains LIMIT" test also matches a
|
|
232
|
+
# LIMIT inside a SUBQUERY, where the OUTER statement still needs its cap.
|
|
233
|
+
# This is tina4-php's SqlNormalizerTrait::hasTrailingLimit regex, ported
|
|
234
|
+
# verbatim so all four frameworks answer identically. It accepts a numeric
|
|
235
|
+
# value, ?/$1/:name/%s placeholders, MySQL's `LIMIT a, b` comma form, and a
|
|
236
|
+
# trailing OFFSET.
|
|
237
|
+
#
|
|
238
|
+
# Literals, quoted identifiers and comments are scrubbed before matching —
|
|
239
|
+
# see .scrub_sql_text for the measured failures that motivate it.
|
|
240
|
+
VALUE_TOKEN = '(?:\d+|\?|\$\d+|:\w+|%s)'
|
|
241
|
+
TRAILING_LIMIT_RE = /
|
|
242
|
+
\bLIMIT\s+#{VALUE_TOKEN}
|
|
243
|
+
(?:\s*,\s*#{VALUE_TOKEN})?
|
|
244
|
+
(?:\s+OFFSET\s+#{VALUE_TOKEN})?
|
|
245
|
+
\s*;?\s*\z
|
|
246
|
+
/ix.freeze
|
|
247
|
+
|
|
248
|
+
def self.has_trailing_limit?(sql)
|
|
249
|
+
return false if sql.nil? || sql.empty?
|
|
250
|
+
TRAILING_LIMIT_RE.match?(scrub_sql_text(sql))
|
|
251
|
+
end
|
|
252
|
+
|
|
139
253
|
# Static factory — cross-framework consistency: Database.create(url)
|
|
140
254
|
def self.create(url, username: "", password: "", pool: nil)
|
|
141
255
|
new(url, username: username.empty? ? nil : username,
|
|
@@ -191,6 +305,9 @@ module Tina4
|
|
|
191
305
|
pool
|
|
192
306
|
end
|
|
193
307
|
@connected = false
|
|
308
|
+
# Set by #connect when connecting fails; re-raised with context by
|
|
309
|
+
# #current_driver so the first real call says what went wrong.
|
|
310
|
+
@connect_error = nil
|
|
194
311
|
|
|
195
312
|
# Per-instance thread-local key for the transaction adapter pin.
|
|
196
313
|
# Without this pin, every Database method call rotates to a different
|
|
@@ -298,12 +415,24 @@ module Tina4
|
|
|
298
415
|
# native toggle (default ON — see @autocommit in #initialize). The
|
|
299
416
|
# framework-level commit in #autocommit_standalone_write covers drivers
|
|
300
417
|
# that have no native setter.
|
|
301
|
-
@driver.autocommit = @autocommit if @driver
|
|
418
|
+
@driver.autocommit = @autocommit if driver_implements?(@driver, :autocommit=)
|
|
302
419
|
|
|
303
420
|
Tina4::Log.info("Database connected: #{@driver_name}")
|
|
421
|
+
@connect_error = nil
|
|
304
422
|
rescue => e
|
|
305
423
|
Tina4::Log.error("Database connection failed: #{e.message}")
|
|
306
424
|
@connected = false
|
|
425
|
+
# REMEMBER the cause. Logging alone is not enough: the driver's own
|
|
426
|
+
# connection stays nil, so the next call used to die inside the driver as
|
|
427
|
+
# NoMethodError: private method `exec' called for nil:NilClass
|
|
428
|
+
# which names neither the database it failed to reach nor why. That cost
|
|
429
|
+
# real debugging time when a missing test database looked like a driver
|
|
430
|
+
# bug. current_driver re-raises this with context instead.
|
|
431
|
+
#
|
|
432
|
+
# Boot still does NOT crash on an unreachable database (deliberate, and
|
|
433
|
+
# the same log-loud-then-degrade policy the session backends use) - the
|
|
434
|
+
# error surfaces at the first actual USE.
|
|
435
|
+
@connect_error = e
|
|
307
436
|
end
|
|
308
437
|
|
|
309
438
|
def close
|
|
@@ -324,6 +453,11 @@ module Tina4
|
|
|
324
453
|
def current_driver
|
|
325
454
|
pinned = Thread.current[@tx_pin_key]
|
|
326
455
|
return pinned if pinned
|
|
456
|
+
|
|
457
|
+
# Fail with the REAL reason, at the point of use. Without this the caller
|
|
458
|
+
# gets a nil dereference from deep inside the driver and has to guess.
|
|
459
|
+
raise Tina4::DatabaseConnectionError, connect_error_message if @connect_error
|
|
460
|
+
|
|
327
461
|
if @pool
|
|
328
462
|
@pool.checkout
|
|
329
463
|
else
|
|
@@ -331,6 +465,30 @@ module Tina4
|
|
|
331
465
|
end
|
|
332
466
|
end
|
|
333
467
|
|
|
468
|
+
# A message that says which database, on which driver, and why - the three
|
|
469
|
+
# things the old nil-dereference told you nothing about.
|
|
470
|
+
def connect_error_message
|
|
471
|
+
target = safe_connection_target
|
|
472
|
+
"Database not connected (#{@driver_name}#{target.empty? ? '' : " -> #{target}"}): " \
|
|
473
|
+
"#{@connect_error.class}: #{@connect_error.message}"
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# The connection string with any password stripped, so a raised error can name
|
|
477
|
+
# the target without leaking a credential into a log or an HTTP 500 body.
|
|
478
|
+
#
|
|
479
|
+
# This used to carry its own regex, which was a SECOND redaction
|
|
480
|
+
# implementation next to DatabaseUrl#to_safe_string - and it leaked on two
|
|
481
|
+
# shapes, both measured 2026-08-02:
|
|
482
|
+
# postgres://u:p@ss@h:5432/db -> postgres://u:***@ss@h:5432/db
|
|
483
|
+
# because `([^:/@]+):[^@]*@` stops at the FIRST "@", so the tail of a
|
|
484
|
+
# password containing an unencoded "@" survived into the message.
|
|
485
|
+
# odbc:///DRIVER=...;PWD=<secret> -> returned VERBATIM, straight into
|
|
486
|
+
# DatabaseConnectionError.
|
|
487
|
+
# There is now ONE redaction primitive. Do not add a second.
|
|
488
|
+
def safe_connection_target
|
|
489
|
+
Tina4::DatabaseUrl.redact(@connection_string)
|
|
490
|
+
end
|
|
491
|
+
|
|
334
492
|
# ── Query Cache ──────────────────────────────────────────────
|
|
335
493
|
|
|
336
494
|
def cache_stats
|
|
@@ -425,10 +583,43 @@ module Tina4
|
|
|
425
583
|
sql = Tina4::Database.strip_trailing_semicolons(sql)
|
|
426
584
|
|
|
427
585
|
effective_sql = sql
|
|
428
|
-
# Skip appending LIMIT if
|
|
429
|
-
|
|
586
|
+
# Skip appending LIMIT if the statement already ENDS with its own.
|
|
587
|
+
#
|
|
588
|
+
# .has_trailing_limit? scrubs literals/identifiers/comments and anchors to
|
|
589
|
+
# the end. The old test was `sql.upcase.split("--")[0].include?("LIMIT")`,
|
|
590
|
+
# so `WHERE label != 'LIMIT'`, a `/* LIMIT 5 */` comment, or a column named
|
|
591
|
+
# rate_limit all read as "the caller supplied their own" and the cap was
|
|
592
|
+
# silently dropped — a full-table read (MEASURED: 150 of 150 rows).
|
|
593
|
+
has_limit = Tina4::Database.has_trailing_limit?(sql)
|
|
594
|
+
applied_limit = 0
|
|
595
|
+
# `limit: 0` means "no cap, give me everything" - the same reading as the
|
|
596
|
+
# Python master's `limit <= 0`. Ruby treats 0 as TRUTHY, so this used to
|
|
597
|
+
# fall through and append a literal `LIMIT 0`, returning ZERO rows where
|
|
598
|
+
# Python returned all of them for the identical call. fetch_all passes nil
|
|
599
|
+
# and was never affected, which is why it went unnoticed.
|
|
600
|
+
limit = nil if limit.is_a?(Numeric) && limit <= 0
|
|
430
601
|
if limit && !has_limit
|
|
431
602
|
effective_sql = drv.apply_limit(effective_sql, limit, offset)
|
|
603
|
+
applied_limit = limit
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# COUNT PROBE: `count` is the TRUE total for the filter, not the number of
|
|
607
|
+
# rows this page returned.
|
|
608
|
+
#
|
|
609
|
+
# Ruby and Node used to populate count with records.length while Python
|
|
610
|
+
# and PHP populated it from a probe, so `db.fetch(sql).count` answered 20
|
|
611
|
+
# here and 250 there for one query against one table - and every paginated
|
|
612
|
+
# response built on it under-reported. MEASURED 2026-08-05 on a 250-row
|
|
613
|
+
# table read with limit=20: Ruby reported total 20 and 1 page against
|
|
614
|
+
# Python's 250 and 13.
|
|
615
|
+
#
|
|
616
|
+
# Only probed when WE appended the pagination. If no limit was applied
|
|
617
|
+
# (fetch_all, or the caller supplied their own LIMIT) the rows returned
|
|
618
|
+
# ARE the whole answer for this SQL, so records.length is already the true
|
|
619
|
+
# total and a second round-trip would buy nothing.
|
|
620
|
+
total = nil
|
|
621
|
+
if applied_limit.positive?
|
|
622
|
+
total = count_probe(drv, sql, params)
|
|
432
623
|
end
|
|
433
624
|
|
|
434
625
|
if @cache_enabled && !no_cache
|
|
@@ -441,13 +632,13 @@ module Tina4
|
|
|
441
632
|
# fetch_direct RAISES on a SQL error (and captures @last_error), so a
|
|
442
633
|
# failed read never reaches cache_set below — we never cache an empty
|
|
443
634
|
# result produced by a buried failure.
|
|
444
|
-
result = fetch_direct(drv, effective_sql, params)
|
|
635
|
+
result = fetch_direct(drv, effective_sql, params, limit: applied_limit, offset: offset, total: total)
|
|
445
636
|
cache_set(key, result)
|
|
446
637
|
@cache_mutex.synchronize { @cache_misses += 1 }
|
|
447
638
|
return result
|
|
448
639
|
end
|
|
449
640
|
|
|
450
|
-
fetch_direct(drv, effective_sql, params)
|
|
641
|
+
fetch_direct(drv, effective_sql, params, limit: applied_limit, offset: offset, total: total)
|
|
451
642
|
end
|
|
452
643
|
|
|
453
644
|
# Fetch a single row (or nil).
|
|
@@ -514,7 +705,7 @@ module Tina4
|
|
|
514
705
|
# — instead of probing a session sequence (lastval()) that returns nil or
|
|
515
706
|
# a stale wrong id for a UUID table. Other engines (SQLite/MySQL/MSSQL/
|
|
516
707
|
# Firebird) keep the generic build-then-last_insert_id path below.
|
|
517
|
-
if
|
|
708
|
+
if driver_implements?(drv, :insert)
|
|
518
709
|
result = drv.insert(table, data)
|
|
519
710
|
autocommit_standalone_write(drv)
|
|
520
711
|
# A driver that owns its insert (PostgreSQL, via RETURNING *) returns a
|
|
@@ -538,61 +729,150 @@ module Tina4
|
|
|
538
729
|
# insert). Mirrors the Python master + PHP, whose write DatabaseResult carries
|
|
539
730
|
# affected_rows/affectedRows; best-effort on drivers without a native count.
|
|
540
731
|
def write_affected(drv, default = 0)
|
|
541
|
-
|
|
732
|
+
driver_implements?(drv, :affected_rows) ? drv.affected_rows.to_i : default
|
|
542
733
|
end
|
|
543
734
|
private :write_affected
|
|
544
735
|
|
|
736
|
+
# The table's primary-key columns, introspected once and cached.
|
|
737
|
+
#
|
|
738
|
+
# Returns an ARRAY because a primary key may span several columns. A
|
|
739
|
+
# composite key is still one primary key; it just has more than one column.
|
|
740
|
+
# Returns [] when the table has no primary key or cannot be introspected.
|
|
741
|
+
#
|
|
742
|
+
# Uses the cross-engine columns() contract (v3.13.14, #48), which reports
|
|
743
|
+
# :primary_key per column on every driver.
|
|
744
|
+
def primary_key(table)
|
|
745
|
+
@pk_cache ||= {}
|
|
746
|
+
unless @pk_cache.key?(table)
|
|
747
|
+
@pk_cache[table] = begin
|
|
748
|
+
columns(table).select { |c| c[:primary_key] }.map { |c| c[:name].to_s }
|
|
749
|
+
rescue StandardError
|
|
750
|
+
[]
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
@pk_cache[table]
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# Normalise a filter to [sql, params], accepting a Hash or a String.
|
|
757
|
+
def as_where(filter, params)
|
|
758
|
+
return ["", []] if filter.nil?
|
|
759
|
+
|
|
760
|
+
if filter.is_a?(Hash)
|
|
761
|
+
return ["", []] if filter.empty?
|
|
762
|
+
|
|
763
|
+
drv = current_driver
|
|
764
|
+
[filter.keys.map { |k| "#{k} = #{drv.placeholder}" }.join(" AND "), filter.values]
|
|
765
|
+
else
|
|
766
|
+
[filter.to_s, Array(params)]
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
private :as_where
|
|
770
|
+
|
|
771
|
+
# Update rows. A write with no filter is an error, not a full-table write.
|
|
772
|
+
#
|
|
773
|
+
# With no explicit filter the primary key is taken out of `data` and used as
|
|
774
|
+
# the WHERE clause. With neither a filter nor a primary key in `data` this
|
|
775
|
+
# raises rather than overwriting every row (audit feature 4, P1).
|
|
545
776
|
def update(table, data, filter = {}, params = nil)
|
|
546
|
-
|
|
547
|
-
|
|
777
|
+
where_sql, where_params = as_where(filter, params)
|
|
778
|
+
data = data.dup
|
|
779
|
+
|
|
780
|
+
if where_sql.empty?
|
|
781
|
+
pk_columns = primary_key(table)
|
|
782
|
+
# Resolve each key column to whichever form the caller used - String or
|
|
783
|
+
# Symbol, and in whatever CASE they typed; nil marks one that is absent.
|
|
784
|
+
#
|
|
785
|
+
# The engines disagree about identifier case BY DESIGN and always will:
|
|
786
|
+
# Firebird folds an unquoted identifier to UPPER, PostgreSQL folds it to
|
|
787
|
+
# LOWER, MySQL and SQLite preserve what was typed. Introspection hands
|
|
788
|
+
# back the ENGINE's spelling while `data` carries the caller's, so an
|
|
789
|
+
# exact match failed on whichever engine folds the other way. That is a
|
|
790
|
+
# case-sensitivity bug, not a Firebird quirk - Firebird merely made it
|
|
791
|
+
# visible first, because the shared write-path contract writes lower-case
|
|
792
|
+
# keys and Firebird reports upper-case ones.
|
|
793
|
+
#
|
|
794
|
+
# Deliberately does NOT downcase what introspection returns: that would
|
|
795
|
+
# special-case one engine and break a genuinely quoted mixed-case table.
|
|
796
|
+
# The WHERE below is still built from the ENGINE's column name (pk_columns);
|
|
797
|
+
# only the VALUE is looked up by the caller's key.
|
|
798
|
+
pk_keys = pk_columns.map do |col|
|
|
799
|
+
folded = col.to_s.downcase
|
|
800
|
+
matches = data.keys.select { |k| k.to_s.downcase == folded }
|
|
801
|
+
if matches.length > 1
|
|
802
|
+
# Ambiguity is refused, never guessed - choosing wrong here writes
|
|
803
|
+
# the WHERE clause of an UPDATE.
|
|
804
|
+
raise ArgumentError,
|
|
805
|
+
"update was given more than one key for the primary-key column " \
|
|
806
|
+
"#{col.inspect}: #{matches.map(&:to_s).sort.inspect} " \
|
|
807
|
+
"(table=#{table.inspect}). These differ only by case, so which one " \
|
|
808
|
+
"identifies the row is ambiguous - pass exactly one, or pass an " \
|
|
809
|
+
"explicit filter."
|
|
810
|
+
end
|
|
811
|
+
matches.first
|
|
812
|
+
end
|
|
813
|
+
missing = pk_columns.each_with_index.reject { |_, i| pk_keys[i] }.map(&:first)
|
|
814
|
+
|
|
815
|
+
if pk_columns.empty? || !missing.empty?
|
|
816
|
+
raise ArgumentError,
|
|
817
|
+
"update requires a filter or the complete primary key in the data; " \
|
|
818
|
+
"pass a filter explicitly to update multiple rows " \
|
|
819
|
+
"(table=#{table.inspect}, primary key=#{pk_columns.inspect}, " \
|
|
820
|
+
"missing from data=#{missing.inspect}). " \
|
|
821
|
+
"To empty a table use truncate(#{table.inspect})."
|
|
822
|
+
end
|
|
548
823
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
824
|
+
# EVERY key column goes into the WHERE. A composite key built from only
|
|
825
|
+
# its first column would match every row sharing that value - the
|
|
826
|
+
# data-loss bug this method exists to prevent, reintroduced.
|
|
827
|
+
where_params = pk_keys.map { |k| data.delete(k) }
|
|
828
|
+
if data.empty?
|
|
829
|
+
raise ArgumentError,
|
|
830
|
+
"update was given only the primary key #{pk_columns.inspect} and no " \
|
|
831
|
+
"columns to set (table=#{table.inspect})"
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
where_sql = pk_columns.map { |c| "#{c} = #{current_driver.placeholder}" }.join(" AND ")
|
|
557
835
|
end
|
|
558
836
|
|
|
837
|
+
cache_invalidate if @cache_enabled
|
|
838
|
+
drv = current_driver
|
|
559
839
|
set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
|
|
560
|
-
|
|
561
|
-
sql
|
|
562
|
-
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
563
|
-
values = data.values + filter.values
|
|
564
|
-
drv.execute(sql, values)
|
|
840
|
+
sql = "UPDATE #{table} SET #{set_parts.join(', ')} WHERE #{where_sql}"
|
|
841
|
+
drv.execute(sql, data.values + where_params)
|
|
565
842
|
autocommit_standalone_write(drv)
|
|
566
|
-
Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
|
|
843
|
+
Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
|
|
567
844
|
end
|
|
568
845
|
|
|
846
|
+
# Delete rows. A filterless delete raises; use truncate() to empty a table.
|
|
569
847
|
def delete(table, filter = {}, params = nil)
|
|
570
|
-
cache_invalidate if @cache_enabled
|
|
571
|
-
drv = current_driver
|
|
572
|
-
|
|
573
848
|
# List of hashes — delete each row
|
|
574
849
|
if filter.is_a?(Array)
|
|
575
850
|
total = 0
|
|
576
851
|
filter.each { |row| total += delete(table, row).affected_rows }
|
|
577
|
-
return Tina4::DatabaseResult.new([], affected_rows: total)
|
|
852
|
+
return Tina4::DatabaseResult.new([], affected_rows: total, last_id: nil)
|
|
578
853
|
end
|
|
579
854
|
|
|
580
|
-
|
|
581
|
-
if
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
autocommit_standalone_write(drv)
|
|
586
|
-
return Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
|
|
855
|
+
where_sql, where_params = as_where(filter, params)
|
|
856
|
+
if where_sql.empty?
|
|
857
|
+
raise ArgumentError,
|
|
858
|
+
"delete requires a filter (table=#{table.inspect}). " \
|
|
859
|
+
"To remove every row use truncate(#{table.inspect})."
|
|
587
860
|
end
|
|
588
861
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
862
|
+
cache_invalidate if @cache_enabled
|
|
863
|
+
drv = current_driver
|
|
864
|
+
drv.execute("DELETE FROM #{table} WHERE #{where_sql}", where_params)
|
|
865
|
+
autocommit_standalone_write(drv)
|
|
866
|
+
Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
# Remove every row. The explicit spelling of a whole-table delete.
|
|
870
|
+
def truncate(table)
|
|
871
|
+
cache_invalidate if @cache_enabled
|
|
872
|
+
drv = current_driver
|
|
873
|
+
drv.execute("DELETE FROM #{table} WHERE 1 = 1", [])
|
|
594
874
|
autocommit_standalone_write(drv)
|
|
595
|
-
Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
|
|
875
|
+
Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
|
|
596
876
|
end
|
|
597
877
|
|
|
598
878
|
# Return the last execute() error message, or nil.
|
|
@@ -685,7 +965,19 @@ module Tina4
|
|
|
685
965
|
begin
|
|
686
966
|
drv.begin_transaction unless already_pinned
|
|
687
967
|
begin
|
|
688
|
-
|
|
968
|
+
# ONE round-trip per CHUNK instead of one per ROW. Looping execute()
|
|
969
|
+
# here pays a full network round-trip for every row: 500 rows took
|
|
970
|
+
# 9848ms on PostgreSQL against 15.8ms as a single multi-row VALUES
|
|
971
|
+
# (625x), MySQL 216x, MSSQL 121x. build_batch_inserts returns an empty
|
|
972
|
+
# array for anything it cannot collapse safely - RETURNING, upserts,
|
|
973
|
+
# non-INSERT statements, ragged rows, Firebird - and the row-at-a-time
|
|
974
|
+
# loop then runs unchanged.
|
|
975
|
+
batched = SQLTranslator.build_batch_inserts(sql, params_list, get_database_type)
|
|
976
|
+
if batched.empty?
|
|
977
|
+
params_list.each { |params| drv.execute(sql, params) }
|
|
978
|
+
else
|
|
979
|
+
batched.each { |chunk_sql, chunk_params| drv.execute(chunk_sql, chunk_params) }
|
|
980
|
+
end
|
|
689
981
|
drv.commit unless already_pinned
|
|
690
982
|
rescue => e
|
|
691
983
|
drv.rollback unless already_pinned
|
|
@@ -702,6 +994,11 @@ module Tina4
|
|
|
702
994
|
nil
|
|
703
995
|
end
|
|
704
996
|
|
|
997
|
+
# last_id stays the LAST inserted row's id. MySQL reports the FIRST id of
|
|
998
|
+
# a multi-row INSERT; its DRIVER normalises that at write time (the only
|
|
999
|
+
# place that knows both the first id and the row count), so get_last_id
|
|
1000
|
+
# and this result always agree. Normalising here instead would
|
|
1001
|
+
# double-apply.
|
|
705
1002
|
Tina4::DatabaseResult.new(
|
|
706
1003
|
[],
|
|
707
1004
|
affected_rows: params_list.length,
|
|
@@ -820,7 +1117,7 @@ module Tina4
|
|
|
820
1117
|
# v3.13.14 (#48): drivers that can resolve a schema/catalog-qualified
|
|
821
1118
|
# name ("gift_cards.gift_card", "dbo.widget", "attached.table") answer
|
|
822
1119
|
# directly; the rest fall back to a case-insensitive scan of tables.
|
|
823
|
-
return drv.table_exists?(table_name) if
|
|
1120
|
+
return drv.table_exists?(table_name) if driver_implements?(drv, :table_exists?)
|
|
824
1121
|
|
|
825
1122
|
tables.any? { |t| t.downcase == table_name.to_s.downcase }
|
|
826
1123
|
end
|
|
@@ -938,10 +1235,52 @@ module Tina4
|
|
|
938
1235
|
# main query propagates (same contract as #execute). The cause is captured on
|
|
939
1236
|
# @last_error for #get_error before the re-raise — preferring the driver's own
|
|
940
1237
|
# last_error (when it exposes one, e.g. postgres) over the exception message.
|
|
941
|
-
|
|
1238
|
+
# `limit:`/`offset:` are the pagination ACTUALLY APPLIED to this statement —
|
|
1239
|
+
# `limit: 0` means "no cap was appended" (an explicit no-limit read, or SQL
|
|
1240
|
+
# that carries its own trailing LIMIT). They were never passed before, so
|
|
1241
|
+
# DatabaseResult#limit fell through to its constructor default and reported
|
|
1242
|
+
# 10 on EVERY fetch whatever limit ran — the same stale v2 number the buried
|
|
1243
|
+
# PHP row-cap test asserted as the cap, sitting one field away from the read
|
|
1244
|
+
# path it fails to describe.
|
|
1245
|
+
# The true row count for `sql`, ignoring the pagination we appended.
|
|
1246
|
+
#
|
|
1247
|
+
# BEST EFFORT BY DESIGN, and it must never mask a real failure: it runs
|
|
1248
|
+
# BEFORE the main query and returns nil on any error, so the main query
|
|
1249
|
+
# still runs and still raises loudly on bad SQL. A probe that swallowed the
|
|
1250
|
+
# error AND the main query's would turn a typo into "no rows".
|
|
1251
|
+
#
|
|
1252
|
+
# nil (not 0) is the miss value. DatabaseResult then falls back to
|
|
1253
|
+
# records.length, which is a true lower bound. Reporting 0 alongside 100
|
|
1254
|
+
# real records - as a failed probe would - is simply false, and it is the
|
|
1255
|
+
# same "envelope states a wrong number authoritatively" defect this whole
|
|
1256
|
+
# change exists to remove.
|
|
1257
|
+
#
|
|
1258
|
+
# The closing paren goes on its OWN LINE. Appended inline, a trailing
|
|
1259
|
+
# `-- comment` in the caller's SQL comments the paren out and the probe dies
|
|
1260
|
+
# with "incomplete input". Postgres, MySQL, MSSQL and ODBC additionally
|
|
1261
|
+
# require a name for the derived table; SQLite and Firebird do not, and
|
|
1262
|
+
# Firebird rejects the `AS` keyword there - so the alias comes from the
|
|
1263
|
+
# driver rather than being assumed.
|
|
1264
|
+
def count_probe(drv, sql, params)
|
|
1265
|
+
return nil unless driver_implements?(drv, :execute_query)
|
|
1266
|
+
|
|
1267
|
+
alias_name = driver_implements?(drv, :count_subquery_alias) ? drv.count_subquery_alias.to_s : ""
|
|
1268
|
+
suffix = alias_name.empty? ? "" : " AS #{alias_name}"
|
|
1269
|
+
rows = drv.execute_query("SELECT COUNT(*) AS tina4_total FROM (#{sql}\n)#{suffix}", params)
|
|
1270
|
+
row = rows.is_a?(Array) ? rows.first : nil
|
|
1271
|
+
return nil unless row.is_a?(Hash)
|
|
1272
|
+
|
|
1273
|
+
value = row["tina4_total"] || row[:tina4_total] || row["TINA4_TOTAL"] || row.values.first
|
|
1274
|
+
value.nil? ? nil : value.to_i
|
|
1275
|
+
rescue StandardError
|
|
1276
|
+
nil
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
def fetch_direct(drv, effective_sql, params, limit: 0, offset: 0, total: nil)
|
|
942
1280
|
result = drv.execute_query(effective_sql, params)
|
|
943
1281
|
@last_error = nil
|
|
944
|
-
Tina4::DatabaseResult.new(result, sql: effective_sql, db: self
|
|
1282
|
+
Tina4::DatabaseResult.new(result, sql: effective_sql, db: self,
|
|
1283
|
+
limit: limit, offset: offset, count: total)
|
|
945
1284
|
rescue => e
|
|
946
1285
|
@last_error = driver_error_message(drv, e)
|
|
947
1286
|
raise
|
|
@@ -965,7 +1304,7 @@ module Tina4
|
|
|
965
1304
|
# Prefer the driver's own last_error (postgres sets one) over str(e), so the
|
|
966
1305
|
# captured message matches what each engine surfaces; never blank.
|
|
967
1306
|
def driver_error_message(drv, error)
|
|
968
|
-
drv_err =
|
|
1307
|
+
drv_err = driver_implements?(drv, :last_error) ? drv.last_error : nil
|
|
969
1308
|
msg = drv_err || error.message
|
|
970
1309
|
msg = error.message if msg.nil? || msg.to_s.empty?
|
|
971
1310
|
msg || @last_error
|
|
@@ -1057,7 +1396,7 @@ module Tina4
|
|
|
1057
1396
|
# lock (NOT via ensure_sequence_table, which would re-enter current_driver/
|
|
1058
1397
|
# table_exists? and risk a nested touch).
|
|
1059
1398
|
def sequence_next_sqlite(drv, seq_name, table, pk_column)
|
|
1060
|
-
conn =
|
|
1399
|
+
conn = driver_implements?(drv, :connection) ? drv.connection : nil
|
|
1061
1400
|
raise "get_next_id: SQLite driver has no live connection" if conn.nil?
|
|
1062
1401
|
|
|
1063
1402
|
Tina4::Drivers::SqliteDriver.write_lock.synchronize do
|
|
@@ -1239,8 +1578,46 @@ module Tina4
|
|
|
1239
1578
|
end
|
|
1240
1579
|
end
|
|
1241
1580
|
|
|
1581
|
+
# Stable identity of the DATABASE a cache entry came from.
|
|
1582
|
+
#
|
|
1583
|
+
# engine://host:port/database - and deliberately NOTHING else.
|
|
1584
|
+
#
|
|
1585
|
+
# WHY IT EXISTS: the key used to be sha256(sql + params) with nothing naming
|
|
1586
|
+
# the connection, so on any SHARED backend two databases cross-served each
|
|
1587
|
+
# other's rows. Two apps pointed at one Redis, or one app with a primary and
|
|
1588
|
+
# an analytics connection, silently read each other's data. Identical SQL
|
|
1589
|
+
# text across tenants is the COMMON case, not an edge case, so the collision
|
|
1590
|
+
# was the normal outcome.
|
|
1591
|
+
#
|
|
1592
|
+
# WHY NO CREDENTIALS: a password in the key means every rotation silently
|
|
1593
|
+
# cold-starts the cache, and a shared backend's key namespace is visible to
|
|
1594
|
+
# every tenant of that backend - a secret must never be folded into it. The
|
|
1595
|
+
# username is out for the same reason plus a second: two connections
|
|
1596
|
+
# differing only by role read the SAME rows and should share the entry.
|
|
1597
|
+
#
|
|
1598
|
+
# WHY NOTHING PER-PROCESS: no pid, no object_id, no salt. Those would
|
|
1599
|
+
# isolate the databases by accident and destroy the point of a shared cache,
|
|
1600
|
+
# because no instance would ever hit another instance's entry.
|
|
1601
|
+
def self.cache_identity(url)
|
|
1602
|
+
parsed = Tina4::DatabaseUrl.new(url.to_s)
|
|
1603
|
+
"#{parsed.engine}://#{parsed.host}:#{parsed.port}/#{parsed.database}"
|
|
1604
|
+
rescue StandardError
|
|
1605
|
+
# An unparseable connection string still needs a STABLE identity, and
|
|
1606
|
+
# falling back to a constant would silently restore the cross-serving bug.
|
|
1607
|
+
# The raw string is stable and distinct; it is only reached for a string
|
|
1608
|
+
# the connection layer is about to reject anyway.
|
|
1609
|
+
url.to_s
|
|
1610
|
+
end
|
|
1611
|
+
private_class_method :cache_identity
|
|
1612
|
+
|
|
1613
|
+
# Generate a cache key from DATABASE IDENTITY + SQL + params.
|
|
1614
|
+
#
|
|
1615
|
+
# The NUL separators keep the three parts from running together, so a table
|
|
1616
|
+
# named after the tail of a database name cannot forge another database's
|
|
1617
|
+
# key.
|
|
1242
1618
|
def cache_key(sql, params)
|
|
1243
|
-
|
|
1619
|
+
raw = "#{self.class.send(:cache_identity, @connection_string)}\x00#{sql}\x00#{params || []}"
|
|
1620
|
+
Digest::SHA256.hexdigest(raw)
|
|
1244
1621
|
end
|
|
1245
1622
|
|
|
1246
1623
|
def cache_get(key)
|
|
@@ -1330,8 +1707,41 @@ module Tina4
|
|
|
1330
1707
|
row.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
1331
1708
|
end
|
|
1332
1709
|
|
|
1710
|
+
# Did the driver actually OVERRIDE this, or is it inheriting the raising
|
|
1711
|
+
# stub from Tina4::DatabaseAdapter?
|
|
1712
|
+
#
|
|
1713
|
+
# These call sites used to ask `respond_to?`. Including the contract module
|
|
1714
|
+
# makes every driver respond to every contract method, so `respond_to?` can
|
|
1715
|
+
# no longer tell "implemented" from "declared and not yet written" - and
|
|
1716
|
+
# answering it wrongly turns a working path into a NotImplementedError.
|
|
1717
|
+
#
|
|
1718
|
+
# Methods outside the contract (affected_rows, last_error, connection) are
|
|
1719
|
+
# not on the module at all, so this degrades to a plain respond_to? for them.
|
|
1720
|
+
def driver_implements?(drv, name)
|
|
1721
|
+
return false if drv.nil?
|
|
1722
|
+
return Tina4::DatabaseAdapter.implemented_by?(drv, name) if
|
|
1723
|
+
Tina4::DatabaseAdapter::CONTRACT.include?(name.to_s.chomp("=").chomp("?").to_sym)
|
|
1724
|
+
|
|
1725
|
+
drv.respond_to?(name)
|
|
1726
|
+
end
|
|
1727
|
+
|
|
1333
1728
|
def detect_driver(conn)
|
|
1334
|
-
|
|
1729
|
+
# A real URL is decided by its SCHEME, through DatabaseUrl, which resolves
|
|
1730
|
+
# aliases once and RAISES on a scheme it does not know.
|
|
1731
|
+
#
|
|
1732
|
+
# The substring matching below is kept only for connection strings that
|
|
1733
|
+
# are not URLs at all (a bare "app.db" path). It used to handle
|
|
1734
|
+
# everything, and its `else "sqlite"` meant an unrecognised URL did not
|
|
1735
|
+
# fail - it quietly became SQLite. The app boots, writes to a local file,
|
|
1736
|
+
# and nobody learns the real database was never reached. It also matched
|
|
1737
|
+
# on substrings, so a postgres database named "mysqldata" could be
|
|
1738
|
+
# detected as MySQL.
|
|
1739
|
+
text = conn.to_s
|
|
1740
|
+
if text.match?(/\A[a-zA-Z][a-zA-Z0-9+.-]*:/)
|
|
1741
|
+
return Tina4::DatabaseUrl.new(text).engine
|
|
1742
|
+
end
|
|
1743
|
+
|
|
1744
|
+
case text.downcase
|
|
1335
1745
|
when /\.db$/, /\.sqlite/, /sqlite/
|
|
1336
1746
|
"sqlite"
|
|
1337
1747
|
when /postgres/, /^pg:/
|