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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module Tina4
|
|
4
4
|
module Drivers
|
|
5
5
|
class MongodbDriver
|
|
6
|
+
include Tina4::DatabaseAdapter
|
|
6
7
|
attr_reader :connection, :db
|
|
7
8
|
|
|
8
9
|
def connect(connection_string, username: nil, password: nil)
|
|
@@ -17,7 +18,17 @@ module Tina4
|
|
|
17
18
|
|
|
18
19
|
uri = build_uri(connection_string, username, password)
|
|
19
20
|
@db_name = extract_db_name(connection_string)
|
|
20
|
-
|
|
21
|
+
# The mongo driver's OWN connect_timeout, in seconds. It already bounds
|
|
22
|
+
# itself - MEASURED: Mongo::Client.new against a TCPServer that accepts
|
|
23
|
+
# and never replies returns after 10.02s, the gem's hard-coded default -
|
|
24
|
+
# so this makes it honour the Tina4 variable instead. A URI that spells
|
|
25
|
+
# connectTimeoutMS itself keeps that value. No bounding_connect wrapper:
|
|
26
|
+
# Client.new does NOT raise on an unreachable server (server selection
|
|
27
|
+
# happens later, on the first operation), so there is no expiry to name.
|
|
28
|
+
seconds = Tina4::DatabaseAdapter.connect_timeout_seconds
|
|
29
|
+
options = {}
|
|
30
|
+
options[:connect_timeout] = seconds if seconds && !uri.include?("connectTimeoutMS")
|
|
31
|
+
@client = Mongo::Client.new(uri, options)
|
|
21
32
|
@db = @client.use(@db_name)
|
|
22
33
|
@connection = @db
|
|
23
34
|
@last_insert_id = nil
|
|
@@ -89,11 +100,15 @@ module Tina4
|
|
|
89
100
|
end
|
|
90
101
|
|
|
91
102
|
# MongoDB has no LIMIT clause — ignore; already handled in execute_query
|
|
103
|
+
# Uses the SAME detector as Database#fetch (scrubbed + anchored to the end)
|
|
104
|
+
# instead of its own naive `sql.upcase.include?("LIMIT")`, which mistook a
|
|
105
|
+
# column named rate_limit or a `'LIMIT'` literal for a real clause and
|
|
106
|
+
# returned the statement uncapped. Appends on a NEW LINE so a trailing
|
|
107
|
+
# `-- comment` cannot swallow the clause.
|
|
92
108
|
def apply_limit(sql, limit, offset = 0)
|
|
93
|
-
|
|
94
|
-
return sql if sql_up.include?("LIMIT")
|
|
109
|
+
return sql if Tina4::Database.has_trailing_limit?(sql)
|
|
95
110
|
modified = sql.dup
|
|
96
|
-
modified += "
|
|
111
|
+
modified += "\nLIMIT #{limit}" if limit && limit > 0
|
|
97
112
|
modified += " OFFSET #{offset}" if offset && offset > 0
|
|
98
113
|
modified
|
|
99
114
|
end
|
|
@@ -5,6 +5,15 @@ require_relative "schema_split"
|
|
|
5
5
|
module Tina4
|
|
6
6
|
module Drivers
|
|
7
7
|
class MssqlDriver
|
|
8
|
+
# Postgres, MySQL, MSSQL and ODBC all REQUIRE a name for a derived
|
|
9
|
+
# table, so the COUNT probe in Database#count_probe wraps as
|
|
10
|
+
# `FROM (sql) AS _count_query`. SQLite and Firebird do not define
|
|
11
|
+
# this and get no alias - Firebird rejects `AS` in that position.
|
|
12
|
+
def count_subquery_alias
|
|
13
|
+
"_count_query"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
include Tina4::DatabaseAdapter
|
|
8
17
|
include SchemaSplit
|
|
9
18
|
attr_reader :connection
|
|
10
19
|
|
|
@@ -18,13 +27,23 @@ module Tina4
|
|
|
18
27
|
" gem install tiny_tds # bare driver"
|
|
19
28
|
end
|
|
20
29
|
uri = parse_connection(connection_string)
|
|
21
|
-
|
|
30
|
+
options = {
|
|
22
31
|
host: uri[:host],
|
|
23
32
|
port: uri[:port] || 1433,
|
|
24
33
|
username: username || uri[:username],
|
|
25
34
|
password: password || uri[:password],
|
|
26
35
|
database: uri[:database]
|
|
27
|
-
|
|
36
|
+
}
|
|
37
|
+
# FreeTDS's OWN login_timeout, in whole seconds - the bound on reaching
|
|
38
|
+
# and logging in to the server. MEASURED: 3.01s ("TDS server connection
|
|
39
|
+
# timed out") against a TCPServer that accepts and never replies, where
|
|
40
|
+
# the same connect with no bound sat past 20s and needed SIGKILL. The
|
|
41
|
+
# separate :timeout (per-query, tiny_tds default 5s) is untouched.
|
|
42
|
+
seconds = Tina4::DatabaseAdapter.connect_timeout_whole_seconds
|
|
43
|
+
options[:login_timeout] = seconds if seconds
|
|
44
|
+
Tina4::DatabaseAdapter.bounding_connect(options[:host], options[:port]) do
|
|
45
|
+
@connection = TinyTds::Client.new(**options)
|
|
46
|
+
end
|
|
28
47
|
end
|
|
29
48
|
|
|
30
49
|
def close
|
|
@@ -51,22 +70,41 @@ module Tina4
|
|
|
51
70
|
# the INSERT and SELECT SCOPE_IDENTITY() in ONE batch (a single
|
|
52
71
|
# @connection.execute), read the id from the SAME batch, and cache it.
|
|
53
72
|
if sql.to_s.lstrip[0, 6].casecmp?("INSERT")
|
|
54
|
-
|
|
73
|
+
# @@ROWCOUNT rides along in the SAME batch for the same reason
|
|
74
|
+
# SCOPE_IDENTITY() does: it reports the row count of the immediately
|
|
75
|
+
# preceding statement (the INSERT), and read in a later batch it would
|
|
76
|
+
# describe something else entirely.
|
|
77
|
+
result = @connection.execute(
|
|
78
|
+
"#{effective_sql}; SELECT SCOPE_IDENTITY() AS id, @@ROWCOUNT AS affected"
|
|
79
|
+
)
|
|
55
80
|
rows = result.each(symbolize_keys: true).to_a
|
|
56
81
|
result.cancel if result.respond_to?(:cancel)
|
|
57
82
|
row = rows.last
|
|
58
83
|
@last_insert_id = row && row[:id] ? row[:id].to_i : nil
|
|
84
|
+
@affected_rows = row && row[:affected] ? row[:affected].to_i : 1
|
|
59
85
|
return true
|
|
60
86
|
end
|
|
61
87
|
|
|
62
88
|
result = @connection.execute(effective_sql)
|
|
63
|
-
|
|
89
|
+
# TinyTds::Result#do runs the statement and RETURNS the number of rows
|
|
90
|
+
# it affected. That count was computed and thrown away: the driver
|
|
91
|
+
# exposed no #affected_rows, so Database#write_affected fell through to
|
|
92
|
+
# its default of 0 and an UPDATE that really changed a row reported
|
|
93
|
+
# affected_rows = 0 — indistinguishable from "matched nothing".
|
|
94
|
+
@affected_rows = result.do
|
|
64
95
|
end
|
|
65
96
|
|
|
66
97
|
def last_insert_id
|
|
67
98
|
@last_insert_id
|
|
68
99
|
end
|
|
69
100
|
|
|
101
|
+
# Rows changed by the most recent INSERT/UPDATE/DELETE on this connection.
|
|
102
|
+
# Parity with SQLite (connection.changes), MySQL (stmt.affected_rows),
|
|
103
|
+
# PostgreSQL (cmd_tuples) and the Python master (cursor.rowcount).
|
|
104
|
+
def affected_rows
|
|
105
|
+
@affected_rows.to_i
|
|
106
|
+
end
|
|
107
|
+
|
|
70
108
|
def placeholder
|
|
71
109
|
"?"
|
|
72
110
|
end
|
|
@@ -81,8 +119,15 @@ module Tina4
|
|
|
81
119
|
# or any unordered SELECT given a limit) otherwise raises "Incorrect
|
|
82
120
|
# syntax near '0'" at the OFFSET. Append a no-op ORDER BY (SELECT NULL)
|
|
83
121
|
# when the SQL has no ORDER BY, mirroring the Python master (mssql.py).
|
|
84
|
-
|
|
85
|
-
|
|
122
|
+
#
|
|
123
|
+
# Both appends go on a NEW LINE: inline they land inside a trailing
|
|
124
|
+
# `-- comment` and are silently swallowed (see the note on
|
|
125
|
+
# Drivers::SqliteDriver#apply_limit). The ORDER BY probe reads the
|
|
126
|
+
# SCRUBBED SQL for the same reason — an ORDER BY that only appears
|
|
127
|
+
# inside a comment or a string literal is not an ORDER BY.
|
|
128
|
+
has_order = Tina4::Database.scrub_sql_text(sql) =~ /\bORDER\s+BY\b/i
|
|
129
|
+
ordered = has_order ? sql : "#{sql}\nORDER BY (SELECT NULL)"
|
|
130
|
+
"#{ordered}\nOFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY"
|
|
86
131
|
end
|
|
87
132
|
|
|
88
133
|
def begin_transaction
|
|
@@ -116,16 +161,34 @@ module Tina4
|
|
|
116
161
|
def columns(table_name)
|
|
117
162
|
# v3.13.14 (#48): honour a schema-qualified name; bare names match any schema.
|
|
118
163
|
schema, tbl = split_schema(table_name)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
164
|
+
# Same hole PostgreSQL had: :primary_key hardcoded false meant
|
|
165
|
+
# Database#primary_key introspected NOTHING on SQL Server, so the
|
|
166
|
+
# feature-4 filterless-write guard rejected every PK-keyed update.
|
|
167
|
+
# Ported from the Python master; the subquery yields every column of the
|
|
168
|
+
# PK, so a COMPOSITE key reports true on each of its columns.
|
|
169
|
+
sql = <<~SQL
|
|
170
|
+
SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT,
|
|
171
|
+
CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS is_primary
|
|
172
|
+
FROM INFORMATION_SCHEMA.COLUMNS c
|
|
173
|
+
LEFT JOIN (
|
|
174
|
+
SELECT ku.COLUMN_NAME
|
|
175
|
+
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
|
|
176
|
+
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku
|
|
177
|
+
ON tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME
|
|
178
|
+
WHERE tc.TABLE_NAME = ? AND (? IS NULL OR tc.TABLE_SCHEMA = ?)
|
|
179
|
+
AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
|
|
180
|
+
) pk ON c.COLUMN_NAME = pk.COLUMN_NAME
|
|
181
|
+
WHERE c.TABLE_NAME = ? AND (? IS NULL OR c.TABLE_SCHEMA = ?)
|
|
182
|
+
ORDER BY c.ORDINAL_POSITION
|
|
183
|
+
SQL
|
|
184
|
+
rows = execute_query(sql, [tbl, schema, schema, tbl, schema, schema])
|
|
122
185
|
rows.map do |r|
|
|
123
186
|
{
|
|
124
187
|
name: r[:COLUMN_NAME] || r[:column_name],
|
|
125
188
|
type: r[:DATA_TYPE] || r[:data_type],
|
|
126
189
|
nullable: (r[:IS_NULLABLE] || r[:is_nullable]) == "YES",
|
|
127
190
|
default: r[:COLUMN_DEFAULT] || r[:column_default],
|
|
128
|
-
primary_key:
|
|
191
|
+
primary_key: (r[:is_primary] || r[:IS_PRIMARY]).to_i == 1
|
|
129
192
|
}
|
|
130
193
|
end
|
|
131
194
|
end
|
|
@@ -5,6 +5,19 @@ require_relative "schema_split"
|
|
|
5
5
|
module Tina4
|
|
6
6
|
module Drivers
|
|
7
7
|
class MysqlDriver
|
|
8
|
+
# Postgres, MySQL, MSSQL and ODBC all REQUIRE a name for a derived
|
|
9
|
+
# table, so the COUNT probe in Database#count_probe wraps as
|
|
10
|
+
# `FROM (sql) AS _count_query`. SQLite and Firebird do not define
|
|
11
|
+
# this and get no alias - Firebird rejects `AS` in that position.
|
|
12
|
+
def count_subquery_alias
|
|
13
|
+
"_count_query"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# First SIX characters of the statements whose row count #affected_rows
|
|
17
|
+
# reports. "REPLAC" is REPLACE truncated to the same width.
|
|
18
|
+
WRITE_VERBS = %w[INSERT UPDATE DELETE REPLAC].freeze
|
|
19
|
+
|
|
20
|
+
include Tina4::DatabaseAdapter
|
|
8
21
|
include SchemaSplit
|
|
9
22
|
attr_reader :connection
|
|
10
23
|
|
|
@@ -28,13 +41,23 @@ module Tina4
|
|
|
28
41
|
# identical socket trap).
|
|
29
42
|
host = uri.host || "127.0.0.1"
|
|
30
43
|
host = "127.0.0.1" if host == "localhost" && uri.port
|
|
31
|
-
|
|
44
|
+
options = {
|
|
32
45
|
host: host,
|
|
33
46
|
port: uri.port || 3306,
|
|
34
47
|
username: username || uri.user,
|
|
35
48
|
password: password || uri.password,
|
|
36
49
|
database: uri.path&.sub("/", "")
|
|
37
|
-
|
|
50
|
+
}
|
|
51
|
+
# mysql2's OWN connect_timeout, in whole seconds. MEASURED: it bounds the
|
|
52
|
+
# handshake read too, not just the TCP connect - 3.01s with "Lost
|
|
53
|
+
# connection ... waiting for initial communication packet" against a
|
|
54
|
+
# TCPServer that accepts and never replies, where the same connect with
|
|
55
|
+
# no bound sat past 20s and needed SIGKILL.
|
|
56
|
+
seconds = Tina4::DatabaseAdapter.connect_timeout_whole_seconds
|
|
57
|
+
options[:connect_timeout] = seconds if seconds
|
|
58
|
+
Tina4::DatabaseAdapter.bounding_connect(options[:host], options[:port]) do
|
|
59
|
+
@connection = Mysql2::Client.new(**options)
|
|
60
|
+
end
|
|
38
61
|
end
|
|
39
62
|
|
|
40
63
|
def close
|
|
@@ -52,6 +75,7 @@ module Tina4
|
|
|
52
75
|
end
|
|
53
76
|
|
|
54
77
|
def execute(sql, params = [])
|
|
78
|
+
stmt = nil
|
|
55
79
|
result =
|
|
56
80
|
if params.empty?
|
|
57
81
|
@connection.query(sql)
|
|
@@ -67,7 +91,40 @@ module Tina4
|
|
|
67
91
|
# returned 0 after an insert (issue #262). Snapshot it for every INSERT so
|
|
68
92
|
# last_insert_id keeps the id of the last insert regardless of any
|
|
69
93
|
# subsequent COMMIT / SELECT on the connection.
|
|
70
|
-
|
|
94
|
+
# MySQL reports the FIRST generated id of a MULTI-ROW INSERT, not the
|
|
95
|
+
# last (verified live: a 3-row insert into a fresh table reports 1 while
|
|
96
|
+
# MAX(id) is 3). Every other engine reports the last, and callers -
|
|
97
|
+
# get_last_id, ORM#save, the batch DatabaseResult - all expect the last.
|
|
98
|
+
# The ids in one statement are consecutive, so normalise here, where both
|
|
99
|
+
# the first id and the row count are known; doing it further up would
|
|
100
|
+
# leave get_last_id disagreeing with the returned result.
|
|
101
|
+
# The row count MUST come from the STATEMENT, not the connection.
|
|
102
|
+
# mysql2's client.affected_rows is unreliable after a prepared
|
|
103
|
+
# execute - measured live, it reported 3 (stale, from the previous
|
|
104
|
+
# query) for a 1-row prepared insert, and 0 for a 3-row one. Using it
|
|
105
|
+
# would shift last_id by a wrong offset. stmt.affected_rows is exact.
|
|
106
|
+
#
|
|
107
|
+
# Hoisted out of the INSERT branch: the count was computed for an INSERT
|
|
108
|
+
# only, so the driver exposed no #affected_rows at all and
|
|
109
|
+
# Database#write_affected fell through to its default of 0 - an UPDATE
|
|
110
|
+
# that really changed a row reported affected_rows = 0, indistinguishable
|
|
111
|
+
# from "matched nothing". Gated on the WRITE verbs because a SELECT's
|
|
112
|
+
# count is its returned-row count, which would clobber the write before
|
|
113
|
+
# it (SQLite's connection.changes has the same last-write-wins rule).
|
|
114
|
+
if WRITE_VERBS.include?(sql.to_s.lstrip[0, 6].upcase)
|
|
115
|
+
@affected_rows = stmt ? stmt.affected_rows.to_i : @connection.affected_rows.to_i
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if sql.to_s.lstrip[0, 6].casecmp?("INSERT")
|
|
119
|
+
first_id = @connection.last_id
|
|
120
|
+
rows = @affected_rows.to_i
|
|
121
|
+
@last_insert_id =
|
|
122
|
+
if first_id.to_i.positive?
|
|
123
|
+
first_id.to_i + [rows, 1].max - 1
|
|
124
|
+
else
|
|
125
|
+
first_id
|
|
126
|
+
end
|
|
127
|
+
end
|
|
71
128
|
result
|
|
72
129
|
end
|
|
73
130
|
|
|
@@ -75,6 +132,13 @@ module Tina4
|
|
|
75
132
|
@last_insert_id
|
|
76
133
|
end
|
|
77
134
|
|
|
135
|
+
# Rows changed by the most recent INSERT/UPDATE/DELETE on this connection.
|
|
136
|
+
# Parity with SQLite (connection.changes), PostgreSQL (cmd_tuples), MSSQL
|
|
137
|
+
# (TinyTds::Result#do) and the Python master (cursor.rowcount).
|
|
138
|
+
def affected_rows
|
|
139
|
+
@affected_rows.to_i
|
|
140
|
+
end
|
|
141
|
+
|
|
78
142
|
def placeholder
|
|
79
143
|
"?"
|
|
80
144
|
end
|
|
@@ -83,8 +147,11 @@ module Tina4
|
|
|
83
147
|
(["?"] * count).join(", ")
|
|
84
148
|
end
|
|
85
149
|
|
|
150
|
+
# NEW LINE, not a space — appended inline the clause lands inside a
|
|
151
|
+
# trailing `-- comment` and the engine ignores it (see the note on
|
|
152
|
+
# Drivers::SqliteDriver#apply_limit).
|
|
86
153
|
def apply_limit(sql, limit, offset = 0)
|
|
87
|
-
"#{sql}
|
|
154
|
+
"#{sql}\nLIMIT #{limit} OFFSET #{offset}"
|
|
88
155
|
end
|
|
89
156
|
|
|
90
157
|
def begin_transaction
|
|
@@ -3,6 +3,15 @@
|
|
|
3
3
|
module Tina4
|
|
4
4
|
module Drivers
|
|
5
5
|
class OdbcDriver
|
|
6
|
+
# Postgres, MySQL, MSSQL and ODBC all REQUIRE a name for a derived
|
|
7
|
+
# table, so the COUNT probe in Database#count_probe wraps as
|
|
8
|
+
# `FROM (sql) AS _count_query`. SQLite and Firebird do not define
|
|
9
|
+
# this and get no alias - Firebird rejects `AS` in that position.
|
|
10
|
+
def count_subquery_alias
|
|
11
|
+
"_count_query"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
include Tina4::DatabaseAdapter
|
|
6
15
|
attr_reader :connection
|
|
7
16
|
|
|
8
17
|
# Connect to an ODBC data source.
|
|
@@ -16,6 +25,28 @@ module Tina4
|
|
|
16
25
|
# passed verbatim to ODBC::Database.new as a connection string.
|
|
17
26
|
# username: and password: are appended as UID/PWD if not already present
|
|
18
27
|
# in the connection string.
|
|
28
|
+
#
|
|
29
|
+
# NOT bounded by TINA4_DATABASE_CONNECT_TIMEOUT, and this is a deliberate
|
|
30
|
+
# exclusion rather than an oversight. Three reasons, in order of weight:
|
|
31
|
+
#
|
|
32
|
+
# 1. ruby-odbc is not a Tina4 dependency (it is in neither the gemspec nor
|
|
33
|
+
# the Gemfile) and its C extension will not build without unixodbc-dev,
|
|
34
|
+
# so it is installed in neither CI nor the lab. A change here could not
|
|
35
|
+
# be tested, and an untestable change to a connect path is exactly the
|
|
36
|
+
# kind of "protection" that turns out not to fire.
|
|
37
|
+
# 2. An ODBC target is a DSN name, a file DSN or a local driver. In the
|
|
38
|
+
# general case there is no host and no port, so the contract's error
|
|
39
|
+
# message has nothing to name.
|
|
40
|
+
# 3. The ODBC-standard bound is SQL_LOGIN_TIMEOUT, which the operator
|
|
41
|
+
# already controls: the DSN string below is passed through verbatim, so
|
|
42
|
+
# a driver-specific `Login Timeout=` / `Connection Timeout=` in it takes
|
|
43
|
+
# effect today with no framework code at all.
|
|
44
|
+
#
|
|
45
|
+
# If ruby-odbc ever becomes testable here, the hook exists:
|
|
46
|
+
# ODBC::Database#login_timeout= maps to SQL_LOGIN_TIMEOUT (odbc.c:5475,
|
|
47
|
+
# bound at odbc.c:9366), reachable by constructing an unconnected
|
|
48
|
+
# ODBC::Database, setting it, then calling #drvconnect (odbc.c:9330)
|
|
49
|
+
# instead of connecting inside ::new.
|
|
19
50
|
def connect(connection_string, username: nil, password: nil)
|
|
20
51
|
begin
|
|
21
52
|
require "odbc"
|
|
@@ -105,18 +136,23 @@ module Tina4
|
|
|
105
136
|
# Tries OFFSET/FETCH NEXT (SQL Server, newer ODBC sources) first.
|
|
106
137
|
# Falls back to LIMIT/OFFSET for sources that support it (MySQL, PostgreSQL via ODBC).
|
|
107
138
|
# The caller (Database#fetch) already gates on whether LIMIT is already present.
|
|
139
|
+
# Every append goes on a NEW LINE: inline it lands inside a trailing
|
|
140
|
+
# `-- comment` and the source silently ignores it (see the note on
|
|
141
|
+
# Drivers::SqliteDriver#apply_limit). The ORDER BY probe reads the SCRUBBED
|
|
142
|
+
# SQL, so an ORDER BY that only appears in a comment or a string literal
|
|
143
|
+
# no longer counts as one.
|
|
108
144
|
def apply_limit(sql, limit, offset = 0)
|
|
109
145
|
offset ||= 0
|
|
110
146
|
if offset > 0
|
|
111
147
|
# SQL Server / ANSI syntax — requires ORDER BY; add a no-op if absent
|
|
112
|
-
if sql.upcase.include?("ORDER BY")
|
|
113
|
-
"#{sql}
|
|
148
|
+
if Tina4::Database.scrub_sql_text(sql).upcase.include?("ORDER BY")
|
|
149
|
+
"#{sql}\nOFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY"
|
|
114
150
|
else
|
|
115
151
|
# LIMIT/OFFSET fallback (MySQL, PostgreSQL via ODBC, SQLite via ODBC)
|
|
116
|
-
"#{sql}
|
|
152
|
+
"#{sql}\nLIMIT #{limit} OFFSET #{offset}"
|
|
117
153
|
end
|
|
118
154
|
else
|
|
119
|
-
"#{sql}
|
|
155
|
+
"#{sql}\nLIMIT #{limit}"
|
|
120
156
|
end
|
|
121
157
|
end
|
|
122
158
|
|
|
@@ -5,6 +5,15 @@ require_relative "schema_split"
|
|
|
5
5
|
module Tina4
|
|
6
6
|
module Drivers
|
|
7
7
|
class PostgresDriver
|
|
8
|
+
# Postgres, MySQL, MSSQL and ODBC all REQUIRE a name for a derived
|
|
9
|
+
# table, so the COUNT probe in Database#count_probe wraps as
|
|
10
|
+
# `FROM (sql) AS _count_query`. SQLite and Firebird do not define
|
|
11
|
+
# this and get no alias - Firebird rejects `AS` in that position.
|
|
12
|
+
def count_subquery_alias
|
|
13
|
+
"_count_query"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
include Tina4::DatabaseAdapter
|
|
8
17
|
include SchemaSplit
|
|
9
18
|
attr_reader :connection
|
|
10
19
|
|
|
@@ -24,7 +33,23 @@ module Tina4
|
|
|
24
33
|
uri.password = password if password
|
|
25
34
|
url = uri.to_s
|
|
26
35
|
end
|
|
27
|
-
|
|
36
|
+
# libpq's OWN connect_timeout, in whole seconds. MEASURED: it bounds the
|
|
37
|
+
# entire connect INCLUDING the startup handshake - 3.01s against a
|
|
38
|
+
# TCPServer that accepts and never replies, where the same connect with
|
|
39
|
+
# no bound sat past 20s and needed SIGKILL. An operator who spelled
|
|
40
|
+
# connect_timeout in the URL themselves keeps their value.
|
|
41
|
+
seconds = Tina4::DatabaseAdapter.connect_timeout_whole_seconds
|
|
42
|
+
seconds = nil if url.include?("connect_timeout=")
|
|
43
|
+
# Host/port for the timeout message only. A libpq keyword/value conninfo
|
|
44
|
+
# string is not a URL and simply yields nil here, which is fine.
|
|
45
|
+
target = begin
|
|
46
|
+
URI.parse(url)
|
|
47
|
+
rescue URI::Error
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
Tina4::DatabaseAdapter.bounding_connect(target&.host, target&.port || 5432) do
|
|
51
|
+
@connection = seconds ? PG.connect(url, connect_timeout: seconds) : PG.connect(url)
|
|
52
|
+
end
|
|
28
53
|
apply_result_type_map(@connection)
|
|
29
54
|
@connection
|
|
30
55
|
end
|
|
@@ -40,6 +65,7 @@ module Tina4
|
|
|
40
65
|
else
|
|
41
66
|
@connection.exec_params(converted_sql, params)
|
|
42
67
|
end
|
|
68
|
+
track_affected(result)
|
|
43
69
|
symbolize_result(result)
|
|
44
70
|
end
|
|
45
71
|
|
|
@@ -52,11 +78,25 @@ module Tina4
|
|
|
52
78
|
# which is the correct source for a sequence-backed bare INSERT.
|
|
53
79
|
@last_returning_id = nil if sql.lstrip[0, 6].upcase == "INSERT"
|
|
54
80
|
converted_sql = convert_placeholders(sql)
|
|
55
|
-
if params.empty?
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
81
|
+
result = if params.empty?
|
|
82
|
+
@connection.exec(converted_sql)
|
|
83
|
+
else
|
|
84
|
+
@connection.exec_params(converted_sql, params)
|
|
85
|
+
end
|
|
86
|
+
track_affected(result)
|
|
87
|
+
result
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Rows changed by the most recent INSERT/UPDATE/DELETE on this connection.
|
|
91
|
+
#
|
|
92
|
+
# Feeds Database#update/delete's DatabaseResult.affected_rows. The driver
|
|
93
|
+
# exposed NO such method, so write_affected fell through to its default of
|
|
94
|
+
# 0 and an UPDATE that really changed a row reported affected_rows = 0 —
|
|
95
|
+
# indistinguishable from "matched nothing". Parity with SQLite
|
|
96
|
+
# (connection.changes), MySQL (stmt.affected_rows) and the Python master
|
|
97
|
+
# (cursor.rowcount).
|
|
98
|
+
def affected_rows
|
|
99
|
+
@affected_rows.to_i
|
|
60
100
|
end
|
|
61
101
|
|
|
62
102
|
# Issue #256: surface the ACTUAL primary key value an INSERT wrote —
|
|
@@ -152,8 +192,11 @@ module Tina4
|
|
|
152
192
|
(1..count).map { |i| "$#{i}" }.join(", ")
|
|
153
193
|
end
|
|
154
194
|
|
|
195
|
+
# NEW LINE, not a space — appended inline the clause lands inside a
|
|
196
|
+
# trailing `-- comment` and the engine ignores it (see the note on
|
|
197
|
+
# Drivers::SqliteDriver#apply_limit).
|
|
155
198
|
def apply_limit(sql, limit, offset = 0)
|
|
156
|
-
"#{sql}
|
|
199
|
+
"#{sql}\nLIMIT #{limit} OFFSET #{offset}"
|
|
157
200
|
end
|
|
158
201
|
|
|
159
202
|
def begin_transaction
|
|
@@ -189,21 +232,65 @@ module Tina4
|
|
|
189
232
|
# v3.13.14 (#48): honour a schema-qualified name; default to public.
|
|
190
233
|
schema, tbl = split_schema(table_name)
|
|
191
234
|
schema ||= "public"
|
|
192
|
-
|
|
193
|
-
|
|
235
|
+
# :primary_key was hardcoded false, so Database#primary_key introspected
|
|
236
|
+
# NOTHING on PostgreSQL. The filterless-write guard (feature 4) reads it,
|
|
237
|
+
# so `update(table, data)` keyed on the primary key in `data` raised
|
|
238
|
+
# "update requires a filter or the complete primary key in the data"
|
|
239
|
+
# against every PostgreSQL table. Port the Python master's LEFT JOIN so
|
|
240
|
+
# the cross-engine columns() contract (#48) actually holds here — the
|
|
241
|
+
# subquery yields every column of the PK, so a COMPOSITE key reports
|
|
242
|
+
# true on each of its columns, not just the first.
|
|
243
|
+
sql = <<~SQL
|
|
244
|
+
SELECT c.column_name, c.data_type, c.is_nullable, c.column_default,
|
|
245
|
+
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_primary
|
|
246
|
+
FROM information_schema.columns c
|
|
247
|
+
LEFT JOIN (
|
|
248
|
+
SELECT ku.column_name
|
|
249
|
+
FROM information_schema.table_constraints tc
|
|
250
|
+
JOIN information_schema.key_column_usage ku
|
|
251
|
+
ON tc.constraint_name = ku.constraint_name
|
|
252
|
+
AND tc.table_schema = ku.table_schema
|
|
253
|
+
WHERE tc.table_name = $1 AND tc.table_schema = $2
|
|
254
|
+
AND tc.constraint_type = 'PRIMARY KEY'
|
|
255
|
+
) pk ON c.column_name = pk.column_name
|
|
256
|
+
WHERE c.table_name = $3 AND c.table_schema = $4
|
|
257
|
+
ORDER BY c.ordinal_position
|
|
258
|
+
SQL
|
|
259
|
+
rows = execute_query(sql, [tbl, schema, tbl, schema])
|
|
194
260
|
rows.map do |r|
|
|
195
261
|
{
|
|
196
262
|
name: r[:column_name],
|
|
197
263
|
type: r[:data_type],
|
|
198
264
|
nullable: r[:is_nullable] == "YES",
|
|
199
265
|
default: r[:column_default],
|
|
200
|
-
|
|
266
|
+
# The result type map decodes bool to true/false, but stay tolerant
|
|
267
|
+
# of the raw "t"/"f" text form if the map could not be built.
|
|
268
|
+
primary_key: r[:is_primary] == true || r[:is_primary] == "t"
|
|
201
269
|
}
|
|
202
270
|
end
|
|
203
271
|
end
|
|
204
272
|
|
|
205
273
|
private
|
|
206
274
|
|
|
275
|
+
# Record the row count of a WRITE so #affected_rows can report it.
|
|
276
|
+
#
|
|
277
|
+
# Gated on the command tag rather than recorded for every statement: a
|
|
278
|
+
# SELECT's cmd_tuples is its ROW COUNT, so tracking it unconditionally
|
|
279
|
+
# would let an ordinary read overwrite the count of the write before it.
|
|
280
|
+
# SQLite's connection.changes has exactly this "last write wins, reads
|
|
281
|
+
# don't touch it" semantic, and Database#write_affected reads the count
|
|
282
|
+
# immediately after the write, so the two agree.
|
|
283
|
+
def track_affected(result)
|
|
284
|
+
tag = result.cmd_status.to_s
|
|
285
|
+
return unless tag.start_with?("INSERT", "UPDATE", "DELETE", "MERGE")
|
|
286
|
+
|
|
287
|
+
@affected_rows = result.cmd_tuples.to_i
|
|
288
|
+
rescue PG::Error, NoMethodError
|
|
289
|
+
# A result that cannot report its status leaves the previous count
|
|
290
|
+
# alone rather than zeroing a real one.
|
|
291
|
+
nil
|
|
292
|
+
end
|
|
293
|
+
|
|
207
294
|
# Issue #256: normalise the ``id`` of an ``INSERT ... RETURNING *`` row.
|
|
208
295
|
#
|
|
209
296
|
# The result-type map already decodes a SERIAL/int8 ``id`` to an Integer
|
|
@@ -5,6 +5,7 @@ require_relative "schema_split"
|
|
|
5
5
|
module Tina4
|
|
6
6
|
module Drivers
|
|
7
7
|
class SqliteDriver
|
|
8
|
+
include Tina4::DatabaseAdapter
|
|
8
9
|
include SchemaSplit
|
|
9
10
|
attr_reader :connection
|
|
10
11
|
|
|
@@ -20,6 +21,11 @@ module Tina4
|
|
|
20
21
|
attr_reader :write_lock
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
# NOT bounded by TINA4_DATABASE_CONNECT_TIMEOUT, deliberately: this connect
|
|
25
|
+
# opens a LOCAL FILE. There is no network peer, so there is no host and no
|
|
26
|
+
# port for the contract's error message to name, and no handshake that can
|
|
27
|
+
# hang. Every other driver's bound lives in its own connect; this is the
|
|
28
|
+
# one that has nothing to bind.
|
|
23
29
|
def connect(connection_string, username: nil, password: nil)
|
|
24
30
|
require "sqlite3"
|
|
25
31
|
db_path = self.class.resolve_path(connection_string)
|
|
@@ -47,7 +53,15 @@ module Tina4
|
|
|
47
53
|
|
|
48
54
|
# Strip the scheme + up to three slashes, preserving a potential fourth
|
|
49
55
|
# slash (absolute) or drive letter.
|
|
50
|
-
|
|
56
|
+
# `sqlite3:` is a documented alias for `sqlite:`. Normalise it FIRST or
|
|
57
|
+
# none of the strips below match and `raw` keeps the whole connection
|
|
58
|
+
# string, so the database file is literally named "sqlite3:app.db".
|
|
59
|
+
# Not merely ugly: a colon is an illegal filename character on Windows,
|
|
60
|
+
# so the documented alias is unusable there. DatabaseUrl already
|
|
61
|
+
# normalises it; this method duplicates the strip instead of calling
|
|
62
|
+
# it, which is how the two drifted.
|
|
63
|
+
normalised = connection_string.sub(/^sqlite3:/, "sqlite:")
|
|
64
|
+
raw = normalised.sub(/^sqlite:\/\/\//, "").sub(/^sqlite:\/\//, "").sub(/^sqlite:/, "")
|
|
51
65
|
return ":memory:" if raw == ":memory:"
|
|
52
66
|
|
|
53
67
|
is_windows_abs = raw.match?(/^[A-Za-z]:[\/\\]/)
|
|
@@ -116,8 +130,13 @@ module Tina4
|
|
|
116
130
|
(["?"] * count).join(", ")
|
|
117
131
|
end
|
|
118
132
|
|
|
133
|
+
# The clause goes on a NEW LINE. Appended inline it lands INSIDE a trailing
|
|
134
|
+
# `-- comment` and is silently swallowed by the engine — MEASURED:
|
|
135
|
+
# "SELECT * FROM t ORDER BY id -- LIMIT 5" returned all 150 rows with the
|
|
136
|
+
# 100-row cap in force. That is a bug at the APPEND SITE, independent of
|
|
137
|
+
# the detector (Database.has_trailing_limit?), and it needs its own test.
|
|
119
138
|
def apply_limit(sql, limit, offset = 0)
|
|
120
|
-
"#{sql}
|
|
139
|
+
"#{sql}\nLIMIT #{limit} OFFSET #{offset}"
|
|
121
140
|
end
|
|
122
141
|
|
|
123
142
|
def begin_transaction
|
|
@@ -169,7 +188,10 @@ module Tina4
|
|
|
169
188
|
type: r[:type],
|
|
170
189
|
nullable: r[:notnull] == 0,
|
|
171
190
|
default: r[:dflt_value],
|
|
172
|
-
|
|
191
|
+
# PRAGMA table_info reports `pk` as the 1-BASED POSITION within the
|
|
192
|
+
# primary key, not a boolean: a composite key gives pk=1, pk=2, ...
|
|
193
|
+
# Testing `== 1` reported only the first column of a composite key.
|
|
194
|
+
primary_key: r[:pk].to_i.positive?
|
|
173
195
|
}
|
|
174
196
|
end
|
|
175
197
|
end
|