tina4ruby 3.13.37 → 3.13.39
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/README.md +7 -7
- data/lib/tina4/api.rb +43 -1
- data/lib/tina4/auth.rb +118 -7
- data/lib/tina4/cli.rb +110 -2
- data/lib/tina4/database.rb +407 -52
- data/lib/tina4/dev_admin.rb +47 -14
- data/lib/tina4/drivers/sqlite_driver.rb +23 -0
- data/lib/tina4/env.rb +40 -4
- data/lib/tina4/events.rb +54 -8
- data/lib/tina4/field_types.rb +5 -2
- data/lib/tina4/graphql.rb +68 -12
- data/lib/tina4/html_element.rb +55 -7
- data/lib/tina4/log.rb +86 -10
- data/lib/tina4/mcp.rb +35 -8
- data/lib/tina4/messenger.rb +130 -25
- data/lib/tina4/metrics.rb +351 -73
- data/lib/tina4/middleware.rb +136 -13
- data/lib/tina4/migration.rb +113 -24
- data/lib/tina4/orm.rb +196 -32
- data/lib/tina4/query_builder.rb +22 -3
- data/lib/tina4/queue_backends/kafka_backend.rb +39 -2
- data/lib/tina4/rack_app.rb +22 -10
- data/lib/tina4/response.rb +31 -11
- data/lib/tina4/router.rb +34 -4
- data/lib/tina4/seeder.rb +433 -84
- data/lib/tina4/session.rb +94 -17
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +458 -21
- data/lib/tina4/wsdl.rb +25 -2
- data/lib/tina4.rb +91 -12
- metadata +6 -47
data/lib/tina4/middleware.rb
CHANGED
|
@@ -41,24 +41,44 @@ module Tina4
|
|
|
41
41
|
@global_middleware = []
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
# Run all "before" hooks: block-based handlers, then class-based before_*
|
|
44
|
+
# Run all "before" hooks: block-based handlers, then class-based before_*
|
|
45
|
+
# methods (in definition order).
|
|
45
46
|
#
|
|
46
47
|
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
47
48
|
# middleware classes explicitly.
|
|
48
49
|
#
|
|
49
|
-
#
|
|
50
|
+
# M2 — visible-but-resilient: every before_* call is wrapped so a THROW
|
|
51
|
+
# never crashes the worker. On a throw the error is LOGGED and the
|
|
52
|
+
# response becomes a clean 500 ({"error":"Internal Server Error",
|
|
53
|
+
# "status":500}), then processing halts (handler skipped) — deterministic,
|
|
54
|
+
# never an unhandled exception. A before_* that sets status >= 400 also
|
|
55
|
+
# halts (the existing 4xx short-circuit). after_* still run on either
|
|
56
|
+
# halt path (see the dispatcher / #run_after docstring).
|
|
57
|
+
#
|
|
58
|
+
# Returns true on success, or false to halt the request (handler skipped).
|
|
50
59
|
def run_before(middleware_classes, request, response)
|
|
51
60
|
# 1. Block-based before handlers (pattern-matched)
|
|
52
61
|
before_handlers.each do |entry|
|
|
53
62
|
next unless matches_pattern?(request.path, entry[:pattern])
|
|
54
|
-
|
|
63
|
+
|
|
64
|
+
begin
|
|
65
|
+
result = entry[:handler].call(request, response)
|
|
66
|
+
rescue StandardError, ScriptError => error
|
|
67
|
+
middleware_500(response, "before handler", error)
|
|
68
|
+
return false
|
|
69
|
+
end
|
|
55
70
|
return false if result == false
|
|
56
71
|
end
|
|
57
72
|
|
|
58
|
-
# 2. Class-based middleware: call every before_* method
|
|
73
|
+
# 2. Class-based middleware: call every before_* method (definition order)
|
|
59
74
|
middleware_classes.each do |klass|
|
|
60
75
|
before_methods_for(klass).each do |method_name|
|
|
61
|
-
|
|
76
|
+
begin
|
|
77
|
+
result = klass.send(method_name, request, response)
|
|
78
|
+
rescue StandardError, ScriptError => error
|
|
79
|
+
middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
|
|
80
|
+
return false
|
|
81
|
+
end
|
|
62
82
|
# Support returning [request, response] (Python convention) or false to halt
|
|
63
83
|
if result == false
|
|
64
84
|
return false
|
|
@@ -73,21 +93,42 @@ module Tina4
|
|
|
73
93
|
true
|
|
74
94
|
end
|
|
75
95
|
|
|
76
|
-
# Run all "after" hooks: block-based handlers, then class-based after_*
|
|
96
|
+
# Run all "after" hooks: block-based handlers, then class-based after_*
|
|
97
|
+
# methods (in definition order).
|
|
77
98
|
#
|
|
78
99
|
# Signature matches Python/PHP/Node orchestrators: pass the list of
|
|
79
100
|
# middleware classes explicitly.
|
|
101
|
+
#
|
|
102
|
+
# AFTER-ON-4xx RULE (M2, documented + consistent across all 4 frameworks):
|
|
103
|
+
# after_* ALWAYS run even when a before_* short-circuited with status >= 400
|
|
104
|
+
# and the handler was skipped — so they can still add headers / logging.
|
|
105
|
+
# The dispatcher calls #run_after unconditionally after the before/handler
|
|
106
|
+
# block (including on the 4xx / throw halt path).
|
|
107
|
+
#
|
|
108
|
+
# M2 — every after_* call is wrapped: a THROW is LOGGED and turns the
|
|
109
|
+
# response into a clean 500, then the REMAINING after_* still run (they
|
|
110
|
+
# may add headers/logging). Never an unhandled crash.
|
|
80
111
|
def run_after(middleware_classes, request, response)
|
|
81
112
|
# 1. Block-based after handlers (pattern-matched)
|
|
82
113
|
after_handlers.each do |entry|
|
|
83
114
|
next unless matches_pattern?(request.path, entry[:pattern])
|
|
84
|
-
|
|
115
|
+
|
|
116
|
+
begin
|
|
117
|
+
entry[:handler].call(request, response)
|
|
118
|
+
rescue StandardError, ScriptError => error
|
|
119
|
+
middleware_500(response, "after handler", error)
|
|
120
|
+
end
|
|
85
121
|
end
|
|
86
122
|
|
|
87
|
-
# 2. Class-based middleware: call every after_* method
|
|
123
|
+
# 2. Class-based middleware: call every after_* method (definition order)
|
|
88
124
|
middleware_classes.each do |klass|
|
|
89
125
|
after_methods_for(klass).each do |method_name|
|
|
90
|
-
|
|
126
|
+
begin
|
|
127
|
+
result = klass.send(method_name, request, response)
|
|
128
|
+
rescue StandardError, ScriptError => error
|
|
129
|
+
middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
|
|
130
|
+
next
|
|
131
|
+
end
|
|
91
132
|
if result.is_a?(Array) && result.length == 2
|
|
92
133
|
request, response = result
|
|
93
134
|
end
|
|
@@ -95,8 +136,38 @@ module Tina4
|
|
|
95
136
|
end
|
|
96
137
|
end
|
|
97
138
|
|
|
139
|
+
# Deterministic clean 500 for a middleware that threw. Logs the cause
|
|
140
|
+
# (NEVER silent) then sets the response to the canonical error shape —
|
|
141
|
+
# byte-identical to the Python master ({"error":"Internal Server Error",
|
|
142
|
+
# "status":500} + status 500). Returns the response for chaining.
|
|
143
|
+
def middleware_500(response, label, error)
|
|
144
|
+
begin
|
|
145
|
+
Tina4::Log.error(
|
|
146
|
+
"Middleware #{label} raised #{error.class.name}: #{error.message}"
|
|
147
|
+
)
|
|
148
|
+
rescue StandardError
|
|
149
|
+
begin
|
|
150
|
+
$stderr.puts("Middleware #{label} raised #{error.class.name}: #{error.message}")
|
|
151
|
+
$stderr.flush
|
|
152
|
+
rescue StandardError
|
|
153
|
+
# never let logging break the worker
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
response.json({ error: "Internal Server Error", status: 500 }, 500)
|
|
157
|
+
end
|
|
158
|
+
|
|
98
159
|
private
|
|
99
160
|
|
|
161
|
+
# Human-readable label for a middleware (class name, or the class of an
|
|
162
|
+
# instance) used in the logged 500 message.
|
|
163
|
+
def class_label(klass)
|
|
164
|
+
if klass.is_a?(Class) || klass.is_a?(Module)
|
|
165
|
+
klass.name || klass.to_s
|
|
166
|
+
else
|
|
167
|
+
klass.class.name || klass.class.to_s
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
100
171
|
def matches_pattern?(path, pattern)
|
|
101
172
|
return true if pattern.nil?
|
|
102
173
|
case pattern
|
|
@@ -109,14 +180,66 @@ module Tina4
|
|
|
109
180
|
end
|
|
110
181
|
end
|
|
111
182
|
|
|
112
|
-
# Collect all
|
|
183
|
+
# Collect all class methods matching before_* in DEFINITION order.
|
|
113
184
|
def before_methods_for(klass)
|
|
114
|
-
klass
|
|
185
|
+
discover_methods(klass, "before_")
|
|
115
186
|
end
|
|
116
187
|
|
|
117
|
-
# Collect all
|
|
188
|
+
# Collect all class methods matching after_* in DEFINITION order.
|
|
118
189
|
def after_methods_for(klass)
|
|
119
|
-
klass
|
|
190
|
+
discover_methods(klass, "after_")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# ----------------------------------------------------------------------
|
|
194
|
+
# MIDDLEWARE ORDERING (M1) — within a class, before_*/after_* methods run
|
|
195
|
+
# in SOURCE-DEFINITION order, NOT alphabetical. Cross-class order is the
|
|
196
|
+
# natural iteration of the registered middleware list (registration
|
|
197
|
+
# order). before_* run before the handler, after_* after.
|
|
198
|
+
#
|
|
199
|
+
# WHY source line numbers, not instance_methods(false): in Ruby/PRISM
|
|
200
|
+
# `instance_methods(false)` is NOT a reliable definition-order report —
|
|
201
|
+
# once a method NAME (symbol) has been defined on any other class first,
|
|
202
|
+
# that name can sort ahead in a later class's list. So we sort the
|
|
203
|
+
# matching methods by their `source_location` line number, which IS the
|
|
204
|
+
# true source-definition order and is immune to the symbol-table quirk.
|
|
205
|
+
# (Methods with no source_location — e.g. C-defined — sort to the front
|
|
206
|
+
# deterministically by name.) We walk the ancestry base→derived so
|
|
207
|
+
# inherited middleware methods run before a subclass's own, de-duping
|
|
208
|
+
# overrides to their first (base) position. Mirrors the Python master's
|
|
209
|
+
# Middleware._discover_methods MRO walk (which leans on __dict__ insertion
|
|
210
|
+
# order — the equivalent of source-definition order).
|
|
211
|
+
def discover_methods(klass, prefix)
|
|
212
|
+
target = klass.is_a?(Class) || klass.is_a?(Module) ? klass.singleton_class : klass.class
|
|
213
|
+
seen = {}
|
|
214
|
+
names = []
|
|
215
|
+
target.ancestors.reverse_each do |ancestor|
|
|
216
|
+
matched = begin
|
|
217
|
+
ancestor.instance_methods(false).select do |name|
|
|
218
|
+
name.to_s.start_with?(prefix) &&
|
|
219
|
+
!seen.key?(name) &&
|
|
220
|
+
klass.respond_to?(name)
|
|
221
|
+
end
|
|
222
|
+
rescue StandardError
|
|
223
|
+
[]
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
ordered = matched.sort_by.with_index do |name, idx|
|
|
227
|
+
line = begin
|
|
228
|
+
loc = ancestor.instance_method(name).source_location
|
|
229
|
+
loc ? loc[1] : -1
|
|
230
|
+
rescue StandardError
|
|
231
|
+
-1
|
|
232
|
+
end
|
|
233
|
+
# Tie-break on the symbol-table index so the result is total/stable.
|
|
234
|
+
[line, idx]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
ordered.each do |name|
|
|
238
|
+
seen[name] = true
|
|
239
|
+
names << name
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
names
|
|
120
243
|
end
|
|
121
244
|
end
|
|
122
245
|
end
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -179,10 +179,12 @@ module Tina4
|
|
|
179
179
|
rescue
|
|
180
180
|
# Generator may already exist
|
|
181
181
|
end
|
|
182
|
+
# migration_name is UNIQUE: a migration is "applied" iff a success row
|
|
183
|
+
# exists, so a re-applied name must never duplicate a tracking row.
|
|
182
184
|
@db.execute(<<~SQL)
|
|
183
185
|
CREATE TABLE #{TRACKING_TABLE} (
|
|
184
186
|
id INTEGER NOT NULL PRIMARY KEY,
|
|
185
|
-
migration_name VARCHAR(500) NOT NULL,
|
|
187
|
+
migration_name VARCHAR(500) NOT NULL UNIQUE,
|
|
186
188
|
description VARCHAR(500) DEFAULT '',
|
|
187
189
|
batch INTEGER NOT NULL DEFAULT 1,
|
|
188
190
|
executed_at VARCHAR(50) DEFAULT CURRENT_TIMESTAMP,
|
|
@@ -190,10 +192,12 @@ module Tina4
|
|
|
190
192
|
)
|
|
191
193
|
SQL
|
|
192
194
|
else
|
|
195
|
+
# migration_name is UNIQUE: a migration is "applied" iff a success row
|
|
196
|
+
# exists, so a re-applied name must never duplicate a tracking row.
|
|
193
197
|
@db.execute(<<~SQL)
|
|
194
198
|
CREATE TABLE #{TRACKING_TABLE} (
|
|
195
199
|
id INTEGER PRIMARY KEY,
|
|
196
|
-
migration_name VARCHAR(255) NOT NULL,
|
|
200
|
+
migration_name VARCHAR(255) NOT NULL UNIQUE,
|
|
197
201
|
description VARCHAR(255) DEFAULT '',
|
|
198
202
|
batch INTEGER NOT NULL DEFAULT 1,
|
|
199
203
|
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
@@ -224,22 +228,37 @@ module Tina4
|
|
|
224
228
|
return [] unless Dir.exist?(@migrations_dir)
|
|
225
229
|
|
|
226
230
|
completed = completed_migrations
|
|
227
|
-
# Support both .rb and .sql migration files
|
|
228
|
-
#
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
231
|
+
# Support both .rb and .sql migration files. Accept both 000001_name.sql
|
|
232
|
+
# (sequential) and YYYYMMDDHHMMSS_name.sql (timestamp) patterns. Sort by a
|
|
233
|
+
# leading numeric/timestamp prefix (numeric-aware) so `9_*` applies before
|
|
234
|
+
# `10_*` — a plain lexical sort misorders unpadded prefixes ("10" < "9").
|
|
235
|
+
# Files with no numeric prefix sort after the numbered ones, lexically.
|
|
236
|
+
files = Dir.glob(File.join(@migrations_dir, "*.{rb,sql}"))
|
|
237
|
+
.reject { |f| f.end_with?(".down.sql") }
|
|
238
|
+
.sort_by { |f| migration_sort_key(File.basename(f)) }
|
|
239
|
+
|
|
240
|
+
# Warn about filenames without a recognized NNNNNN_/timestamp prefix —
|
|
241
|
+
# their ordering relative to numbered migrations is undefined, a silent
|
|
242
|
+
# out-of-order-apply footgun.
|
|
243
|
+
unprefixed = files.map { |f| File.basename(f) }.reject { |n| n =~ /\A\d+[_-]/ }
|
|
244
|
+
unless unprefixed.empty?
|
|
245
|
+
Tina4::Log.warning(
|
|
246
|
+
"Migration file(s) without a numeric/timestamp prefix may apply out of order: " +
|
|
247
|
+
unprefixed.join(", ")
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
files.reject { |f| completed.include?(File.basename(f)) }
|
|
233
252
|
end
|
|
234
253
|
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
254
|
+
# Numeric-aware sort key so `9_name.sql` sorts before `10_name.sql` (plain
|
|
255
|
+
# lexical sort puts "10" before "9"). Files with a leading numeric/timestamp
|
|
256
|
+
# prefix sort first by that number; the rest sort after, lexically.
|
|
238
257
|
def migration_sort_key(filename)
|
|
239
258
|
if filename =~ /\A(\d+)/
|
|
240
|
-
[$1.to_i, filename]
|
|
259
|
+
[0, $1.to_i, filename]
|
|
241
260
|
else
|
|
242
|
-
[0, filename]
|
|
261
|
+
[1, 0, filename]
|
|
243
262
|
end
|
|
244
263
|
end
|
|
245
264
|
|
|
@@ -247,16 +266,27 @@ module Tina4
|
|
|
247
266
|
name = File.basename(file)
|
|
248
267
|
Tina4::Log.info("Running migration: #{name}")
|
|
249
268
|
begin
|
|
269
|
+
# Wrap each migration FILE in its own transaction so a multi-statement
|
|
270
|
+
# file that fails midway rolls back as a unit. Truly atomic only on
|
|
271
|
+
# engines with transactional DDL (PostgreSQL); MySQL/Firebird/SQLite
|
|
272
|
+
# auto-commit DDL, so earlier statements may persist there — keep one
|
|
273
|
+
# logical change per file.
|
|
274
|
+
@db.start_transaction
|
|
250
275
|
if file.end_with?(".rb")
|
|
251
276
|
execute_ruby_migration(file, :up)
|
|
252
277
|
else
|
|
253
278
|
execute_sql_file(file)
|
|
254
279
|
end
|
|
280
|
+
# ROW-EXISTENCE tracking: only a SUCCESS row is ever written. A
|
|
281
|
+
# migration is "applied" iff a success row exists — failures are never
|
|
282
|
+
# recorded, nothing is deleted, the file rolls back and we surface the
|
|
283
|
+
# error. Fix the bad file and re-run.
|
|
255
284
|
_record_migration(name, batch, passed: 1)
|
|
285
|
+
@db.commit
|
|
256
286
|
{ name: name, status: "success" }
|
|
257
287
|
rescue => e
|
|
288
|
+
@db.rollback rescue nil
|
|
258
289
|
Tina4::Log.error("Migration failed: #{name} - #{e.message}")
|
|
259
|
-
_record_migration(name, batch, passed: 0)
|
|
260
290
|
{ name: name, status: "failed", error: e.message }
|
|
261
291
|
end
|
|
262
292
|
end
|
|
@@ -296,6 +326,26 @@ module Tina4
|
|
|
296
326
|
migration.__send__(direction, @db)
|
|
297
327
|
end
|
|
298
328
|
|
|
329
|
+
# Smart/curly quotes — editors, word processors, docs and chat apps silently
|
|
330
|
+
# convert a straight " to “ ” and a straight ' to ‘ ’ (plus primes ′ ″). Those
|
|
331
|
+
# characters are NOT valid SQL string/identifier delimiters, so a pasted-in
|
|
332
|
+
# migration fails to run ("syntax error near …"). Map them back to straight
|
|
333
|
+
# ASCII quotes. (Real string CONTENTS are unaffected by intent — we only swap
|
|
334
|
+
# the lookalike code points for their ASCII equivalents.)
|
|
335
|
+
SMART_QUOTES = {
|
|
336
|
+
"“" => '"', "”" => '"', "„" => '"', "‟" => '"', "″" => '"', # “ ” „ ‟ ″
|
|
337
|
+
"‘" => "'", "’" => "'", "‚" => "'", "‛" => "'", "′" => "'" # ‘ ’ ‚ ‛ ′
|
|
338
|
+
}.freeze
|
|
339
|
+
SMART_QUOTE_RE = Regexp.union(SMART_QUOTES.keys)
|
|
340
|
+
|
|
341
|
+
# Replace smart/curly quotes with straight ASCII quotes so migration SQL
|
|
342
|
+
# authored or pasted from an editor/doc actually runs (those code points are
|
|
343
|
+
# not valid SQL delimiters). Already-straight quotes and ordinary content are
|
|
344
|
+
# returned byte-for-byte unchanged.
|
|
345
|
+
def normalize_quotes(sql)
|
|
346
|
+
sql.gsub(SMART_QUOTE_RE, SMART_QUOTES)
|
|
347
|
+
end
|
|
348
|
+
|
|
299
349
|
# Split SQL into individual statements, handling:
|
|
300
350
|
# - $$ delimited stored procedure blocks
|
|
301
351
|
# - // delimited blocks
|
|
@@ -303,6 +353,10 @@ module Tina4
|
|
|
303
353
|
# - Line comments -- ...
|
|
304
354
|
# Matches the Python/Node.js approach: extract blocks first, split on ;, restore blocks.
|
|
305
355
|
def split_sql_statements(sql, delimiter = ";")
|
|
356
|
+
# Normalize smart/curly quotes to straight ASCII first, so SQL pasted from
|
|
357
|
+
# an editor/doc (which converts " → “ ” and ' → ‘ ’) actually runs.
|
|
358
|
+
sql = normalize_quotes(sql)
|
|
359
|
+
|
|
306
360
|
blocks = []
|
|
307
361
|
|
|
308
362
|
# Extract $$ ... $$ blocks (stored procedures, triggers, etc.)
|
|
@@ -311,8 +365,12 @@ module Tina4
|
|
|
311
365
|
"__BLOCK_#{blocks.length - 1}__"
|
|
312
366
|
end
|
|
313
367
|
|
|
314
|
-
# Extract // ... // blocks
|
|
315
|
-
|
|
368
|
+
# Extract // ... // blocks (stored procedures, triggers, etc.). The `//`
|
|
369
|
+
# delimiters must NOT be preceded by a colon, so a URL scheme
|
|
370
|
+
# (`https://…`) or other `://` literal inside a migration is never
|
|
371
|
+
# captured as an opaque stored-proc block (it would otherwise swallow
|
|
372
|
+
# everything between two `//` occurrences and skip statement splitting).
|
|
373
|
+
processed = processed.gsub(/(?<!:)\/\/(.*?)(?<!:)\/\//m) do
|
|
316
374
|
blocks << $~.to_s
|
|
317
375
|
"__BLOCK_#{blocks.length - 1}__"
|
|
318
376
|
end
|
|
@@ -348,18 +406,21 @@ module Tina4
|
|
|
348
406
|
sql = File.read(file)
|
|
349
407
|
statements = split_sql_statements(sql)
|
|
350
408
|
statements.each do |stmt|
|
|
351
|
-
#
|
|
352
|
-
#
|
|
353
|
-
#
|
|
354
|
-
|
|
409
|
+
# Idempotency on engines lacking IF NOT EXISTS: Firebird ALTER-TABLE-ADD
|
|
410
|
+
# (pre-check the system catalogue for a duplicate column), and CREATE
|
|
411
|
+
# TABLE on Firebird/MSSQL (pre-check the table exists). Only a genuine
|
|
412
|
+
# already-exists is skipped — every other error still raises.
|
|
413
|
+
skip_reason = should_skip_for_firebird(stmt) || should_skip_create_table(stmt)
|
|
355
414
|
if skip_reason
|
|
356
415
|
Tina4::Log.info("Migration #{File.basename(file)}: #{skip_reason}")
|
|
357
416
|
next
|
|
358
417
|
end
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
418
|
+
# db.execute() now RAISES on a SQL error (it no longer returns false),
|
|
419
|
+
# so a bad statement throws straight up to run_migration's rescue, which
|
|
420
|
+
# records the migration as failed and surfaces the error. The old
|
|
421
|
+
# "if result == false: raise" check is dead — a plain execute carries
|
|
422
|
+
# the same failure semantics.
|
|
423
|
+
@db.execute(stmt)
|
|
363
424
|
end
|
|
364
425
|
end
|
|
365
426
|
|
|
@@ -396,6 +457,34 @@ module Tina4
|
|
|
396
457
|
end
|
|
397
458
|
end
|
|
398
459
|
|
|
460
|
+
# CREATE TABLE <name> — name may be quoted ("x"), bracketed ([x] MSSQL), or bare.
|
|
461
|
+
CREATE_TABLE_RE = /\A\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|\[([^\]]+)\]|(\w+))/i
|
|
462
|
+
|
|
463
|
+
# Make CREATE TABLE idempotent on engines lacking IF NOT EXISTS.
|
|
464
|
+
#
|
|
465
|
+
# Firebird and MSSQL do not support `CREATE TABLE IF NOT EXISTS`, so a raw
|
|
466
|
+
# CREATE in a re-run migration raises "object already exists". When the
|
|
467
|
+
# target table already exists on those engines, returns a skip reason so the
|
|
468
|
+
# statement is skipped (mirrors the Firebird ALTER-TABLE-ADD idempotency
|
|
469
|
+
# guard). SQLite/MySQL/PostgreSQL support IF NOT EXISTS and are left to the
|
|
470
|
+
# engine. Only a genuine already-exists is skipped — every other error still
|
|
471
|
+
# raises. Returns nil if the statement should execute normally.
|
|
472
|
+
def should_skip_create_table(stmt)
|
|
473
|
+
engine = @db.get_database_type rescue nil
|
|
474
|
+
return nil unless %w[firebird mssql].include?(engine)
|
|
475
|
+
|
|
476
|
+
m = stmt.match(CREATE_TABLE_RE)
|
|
477
|
+
return nil unless m
|
|
478
|
+
|
|
479
|
+
table = m[1] || m[2] || m[3]
|
|
480
|
+
begin
|
|
481
|
+
return "Table #{table} already exists, skipping CREATE TABLE" if @db.table_exists?(table)
|
|
482
|
+
rescue
|
|
483
|
+
return nil
|
|
484
|
+
end
|
|
485
|
+
nil
|
|
486
|
+
end
|
|
487
|
+
|
|
399
488
|
def _record_migration(name, batch, passed: 1)
|
|
400
489
|
# Extract description from filename (strip numeric prefix and extension)
|
|
401
490
|
stem = File.basename(name, File.extname(name))
|