tina4ruby 3.13.94 → 3.13.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +256 -33
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. 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
- @client = Mongo::Client.new(uri)
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
- sql_up = sql.upcase
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 += " LIMIT #{limit}" if limit && limit > 0
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
- @connection = TinyTds::Client.new(
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
- result = @connection.execute("#{effective_sql}; SELECT SCOPE_IDENTITY() AS id")
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
- result.do
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
- ordered = sql =~ /\bORDER\s+BY\b/i ? sql : "#{sql} ORDER BY (SELECT NULL)"
85
- "#{ordered} OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY"
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
- sql = "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS " \
120
- "WHERE TABLE_NAME = ? AND (? IS NULL OR TABLE_SCHEMA = ?)"
121
- rows = execute_query(sql, [tbl, schema, schema])
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: false
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
- @connection = Mysql2::Client.new(
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
- @last_insert_id = @connection.last_id if sql.to_s.lstrip[0, 6].casecmp?("INSERT")
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} LIMIT #{limit} OFFSET #{offset}"
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} OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY"
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} LIMIT #{limit} OFFSET #{offset}"
152
+ "#{sql}\nLIMIT #{limit} OFFSET #{offset}"
117
153
  end
118
154
  else
119
- "#{sql} LIMIT #{limit}"
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
- @connection = PG.connect(url)
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
- @connection.exec(converted_sql)
57
- else
58
- @connection.exec_params(converted_sql, params)
59
- end
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} LIMIT #{limit} OFFSET #{offset}"
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
- sql = "SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = $1 AND table_schema = $2"
193
- rows = execute_query(sql, [tbl, schema])
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
- primary_key: false
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
- raw = connection_string.sub(/^sqlite:\/\/\//, "").sub(/^sqlite:\/\//, "").sub(/^sqlite:/, "")
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} LIMIT #{limit} OFFSET #{offset}"
139
+ "#{sql}\nLIMIT #{limit} OFFSET #{offset}"
121
140
  end
122
141
 
123
142
  def begin_transaction