Class: Tina4::SQLTranslator
- Inherits:
-
Object
- Object
- Tina4::SQLTranslator
- Defined in:
- lib/tina4/sql_translator.rb
Overview
Cross-engine SQL translator.
Each database adapter calls the rules it needs. Rules are composable and stateless -- just string transforms.
Also includes query caching with TTL support.
Usage:
translated = Tina4::SQLTranslator.limit_to_rows("SELECT * FROM users LIMIT 10 OFFSET 5")
# => "SELECT * FROM users ROWS 6 TO 15"
Constant Summary collapse
- PRIMARY =
A concat/ilike operand: a masked literal-or-identifier token, a simple function call, a (qualified) identifier, a placeholder, or a number. The function-call args exclude
|so a nested||never splits the chain. '(?:\x00\d+\x00|[A-Za-z_][\w$]*\s*\([^()|]*\)|[A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)*|:[A-Za-z_]\w*|\$\d+|\?|%s|\d+(?:\.\d+)?)'- MAX_BIND_PARAMS =
Hard per-statement bind-parameter ceiling per engine. 0 = never collapse. Sourced from spec/fixtures/batch_write_contract.json, byte-identical in all four frameworks.
{ "sqlite" => 999, "postgres" => 65_535, "mysql" => 65_535, "mssql" => 2_100, "firebird" => 0, "odbc" => 0, "mongodb" => 0 }.freeze
- ENGINE_ALIASES =
The four frameworks do not agree on what an engine calls itself - Python and PHP report "postgresql", Ruby and Node report "postgres". Without normalising, the cap lookup misses and the collapse silently does nothing on the engine with the largest win.
{ "postgresql" => "postgres", "pgsql" => "postgres", "sqlite3" => "sqlite", "sqlserver" => "mssql", "sqlsrv" => "mssql", "mariadb" => "mysql" }.freeze
- INSERT_VALUES =
/\A\s*INSERT\s+INTO\s+.+?\s+VALUES\s*\(([^()]*)\)\s*\z/im- FIRST_ID_ENGINES =
Engines whose last_insert_id reports the FIRST generated id of a multi-row INSERT rather than the last. Verified live against MySQL.
%w[mysql].freeze
Class Method Summary collapse
-
.auto_increment_syntax(sql, engine) ⇒ String
Translate AUTOINCREMENT across engines in DDL.
-
.batch_last_id(reported_id, rows_in_chunk, engine) ⇒ Array<Array(String, Array)>
Collapse a row-at-a-time INSERT batch into chunked multi-row VALUES.
-
.boolean_to_int(sql) ⇒ String
Convert a bare TRUE/FALSE to 1/0 for engines without a boolean type.
- .build_batch_inserts(sql, params_list, engine) ⇒ Object
-
.concat_pipes_to_func(sql) ⇒ String
Convert || string concatenation to CONCAT() for MySQL/MSSQL.
-
.ilike_to_like(sql) ⇒ String
Convert
col ILIKE patterntoLOWER(col) LIKE LOWER(pattern)for engines without ILIKE. -
.mask_literals(sql) ⇒ Object
Replace string literals, quoted identifiers and comments with opaque "\x00N\x00" tokens.
-
.restore_literals(masked, literals) ⇒ Object
Inverse of #mask_literals.
Class Method Details
.auto_increment_syntax(sql, engine) ⇒ String
Translate AUTOINCREMENT across engines in DDL.
152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/tina4/sql_translator.rb', line 152 def auto_increment_syntax(sql, engine) case engine when "mysql" sql.gsub("AUTOINCREMENT", "AUTO_INCREMENT") when "postgresql" # BIGINT PRIMARY KEY AUTOINCREMENT -> BIGSERIAL (a real 64-bit sequence); # INTEGER -> SERIAL. A plain BIGINT with the keyword merely stripped has # no sequence and cannot auto-increment. sql .gsub(/\bBIGINT\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/i, "BIGSERIAL PRIMARY KEY") .gsub(/\bINTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/i, "SERIAL PRIMARY KEY") .gsub(/\s*\bAUTOINCREMENT\b/i, "") when "mssql" sql.gsub(/AUTOINCREMENT/i, "IDENTITY(1,1)") when "firebird" sql.gsub(/\s*AUTOINCREMENT\b/i, "") else sql end end |
.batch_last_id(reported_id, rows_in_chunk, engine) ⇒ Array<Array(String, Array)>
Collapse a row-at-a-time INSERT batch into chunked multi-row VALUES.
A batch that loops one INSERT per row pays a full network round-trip per row, and the round-trip - not SQL building - is the entire cost of a batch write. Measured over 500 rows: PostgreSQL 9848ms row-at-a-time against 15.8ms as a single multi-row statement (625x), MySQL 216x, MSSQL 121x.
PURE: no I/O and no engine contact, so the chunking rules are checkable without a database. The live-engine runners prove the rows land.
Normalise a collapsed batch's last id to the LAST row's id.
A row-at-a-time batch reports the last row's id simply because the last statement inserted the last row. Collapsing rows into one statement changes that on any engine that reports the FIRST generated id, so this restores the contract instead of quietly redefining it.
Verified live, not assumed: a 3-row insert into a fresh MySQL table reports 1 while MAX(id) is 3. SQLite, PostgreSQL and MSSQL already report the last and are left alone. The ids in one statement are consecutive, so the last is +first + rows - 1+.
209 210 211 212 213 214 215 216 |
# File 'lib/tina4/sql_translator.rb', line 209 def batch_last_id(reported_id, rows_in_chunk, engine) name = engine.to_s.downcase name = ENGINE_ALIASES.fetch(name, name) return reported_id unless FIRST_ID_ENGINES.include?(name) return reported_id unless reported_id.is_a?(Integer) || reported_id.to_s.match?(/\A-?\d+\z/) reported_id.to_i + [rows_in_chunk.to_i, 1].max - 1 end |
.boolean_to_int(sql) ⇒ String
Convert a bare TRUE/FALSE to 1/0 for engines without a boolean type. A TRUE/FALSE INSIDE a string literal is data and is left untouched.
122 123 124 125 126 127 128 |
# File 'lib/tina4/sql_translator.rb', line 122 def boolean_to_int(sql) return sql unless sql.match?(/\b(?:TRUE|FALSE)\b/i) masked, literals = mask_literals(sql) masked = masked.gsub(/\bTRUE\b/i, "1").gsub(/\bFALSE\b/i, "0") restore_literals(masked, literals) end |
.build_batch_inserts(sql, params_list, engine) ⇒ Object
218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 |
# File 'lib/tina4/sql_translator.rb', line 218 def build_batch_inserts(sql, params_list, engine) rows = params_list || [] return [] if rows.length < 2 name = engine.to_s.downcase name = ENGINE_ALIASES.fetch(name, name) cap = MAX_BIND_PARAMS.fetch(name, 0) # Firebird has no multi-row VALUES syntax; ODBC's real ceiling depends on # the driver behind it. Emitting SQL the engine cannot parse to save a # round-trip is not a trade worth making. return [] if cap <= 0 upper = sql.upcase # A collapsed statement returns N rows where the caller expects one, and # conflict arbitration changes once rows share a statement. return [] if upper.include?("RETURNING") || upper.include?("ON CONFLICT") || upper.include?("ON DUPLICATE KEY") match = INSERT_VALUES.match(sql) return [] if match.nil? # Every slot must be a bare placeholder. `now()` repeated per row inside # one statement is not the same write as `now()` evaluated per statement. slots = match[1].split(",").map(&:strip) return [] if slots.empty? || slots.any? { |slot| slot != "?" } columns = slots.length return [] if rows.any? { |params| params.length != columns } chunk_rows = [1, cap / columns].max return [] if chunk_rows < 2 head = sql[0...(match.begin(1) - 1)].rstrip one_row = "(#{Array.new(columns, '?').join(', ')})" rows.each_slice(chunk_rows).map do |chunk| ["#{head} #{Array.new(chunk.length, one_row).join(', ')}", chunk.flatten(1)] end end |
.concat_pipes_to_func(sql) ⇒ String
Convert || string concatenation to CONCAT() for MySQL/MSSQL. Rewrites ONLY || operators joining expression operands OUTSIDE any string literal or comment, and only the operand chain - never the whole statement.
SELECT a || b FROM t => SELECT CONCAT(a, b) FROM t
WHERE data = 'a||b' => WHERE data = 'a||b' (literal untouched)
104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/tina4/sql_translator.rb', line 104 def concat_pipes_to_func(sql) return sql unless sql.include?("||") masked, literals = mask_literals(sql) return sql unless masked.include?("||") chain = /#{SQLTranslator::PRIMARY}(?:\s*\|\|\s*#{SQLTranslator::PRIMARY})+/ rewritten = masked.gsub(chain) do |m| "CONCAT(#{m.split(/\s*\|\|\s*/).join(', ')})" end restore_literals(rewritten, literals) end |
.ilike_to_like(sql) ⇒ String
Convert col ILIKE pattern to LOWER(col) LIKE LOWER(pattern) for engines
without ILIKE. The pattern operand is captured whole (a multi-word
'%two words%' survives), and an ILIKE INSIDE a string literal is untouched.
136 137 138 139 140 141 142 143 144 145 |
# File 'lib/tina4/sql_translator.rb', line 136 def ilike_to_like(sql) return sql unless sql =~ /ilike/i masked, literals = mask_literals(sql) pattern = /(#{SQLTranslator::PRIMARY})\s+ILIKE\s+(#{SQLTranslator::PRIMARY})/i rewritten = masked.gsub(pattern) do "LOWER(#{::Regexp.last_match(1)}) LIKE LOWER(#{::Regexp.last_match(2)})" end restore_literals(rewritten, literals) end |
.mask_literals(sql) ⇒ Object
Replace string literals, quoted identifiers and comments with opaque "\x00N\x00" tokens. Returns [masked_sql, literals]; doubled-quote escapes ('' "" ``) are handled so an embedded quote never ends the span early.
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/tina4/sql_translator.rb', line 42 def mask_literals(sql) literals = [] out = +"" i = 0 n = sql.length while i < n ch = sql[i] nxt = sql[i + 1] if ch == "'" || ch == '"' || ch == "`" start = i i += 1 while i < n if sql[i] == ch if sql[i + 1] == ch i += 2 next end i += 1 break end i += 1 end out << "\x00#{literals.length}\x00" literals << sql[start...i] next end if ch == "-" && nxt == "-" start = i i += 1 while i < n && sql[i] != "\n" out << "\x00#{literals.length}\x00" literals << sql[start...i] next end if ch == "/" && nxt == "*" start = i i += 2 i += 1 while i < n && !(sql[i] == "*" && sql[i + 1] == "/") i = [i + 2, n].min out << "\x00#{literals.length}\x00" literals << sql[start...i] next end out << ch i += 1 end [out, literals] end |
.restore_literals(masked, literals) ⇒ Object
Inverse of #mask_literals.
91 92 93 |
# File 'lib/tina4/sql_translator.rb', line 91 def restore_literals(masked, literals) masked.gsub(/\x00(\d+)\x00/) { literals[::Regexp.last_match(1).to_i] } end |