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.
@@ -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_* methods.
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
- # Returns [request, response] on success, or false to halt the request.
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
- result = entry[:handler].call(request, response)
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
- result = klass.send(method_name, request, response)
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_* methods.
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
- entry[:handler].call(request, response)
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
- result = klass.send(method_name, request, response)
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 public class methods matching before_*
183
+ # Collect all class methods matching before_* in DEFINITION order.
113
184
  def before_methods_for(klass)
114
- klass.methods(false).select { |m| m.to_s.start_with?("before_") }.sort
185
+ discover_methods(klass, "before_")
115
186
  end
116
187
 
117
- # Collect all public class methods matching after_*
188
+ # Collect all class methods matching after_* in DEFINITION order.
118
189
  def after_methods_for(klass)
119
- klass.methods(false).select { |m| m.to_s.start_with?("after_") }.sort
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
@@ -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
- # Accept both 000001_name.sql (sequential) and YYYYMMDDHHMMSS_name.sql (timestamp) patterns
229
- Dir.glob(File.join(@migrations_dir, "*.{rb,sql}"))
230
- .reject { |f| f.end_with?(".down.sql") }
231
- .sort_by { |f| migration_sort_key(File.basename(f)) }
232
- .reject { |f| completed.include?(File.basename(f)) }
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
- # Sort key that handles both 000001_name.sql and 20240315120000_name.sql patterns.
236
- # Both are zero-padded numeric prefixes so alphabetical sorting works, but we
237
- # extract the prefix explicitly to guarantee correct ordering when mixed.
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
- processed = processed.gsub(/\/\/(.*?)\/\//m) do
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
- # Firebird lacks IF NOT EXISTS for ALTER TABLE ADD.
352
- # Pre-check the system catalogue so duplicate columns are
353
- # silently skipped instead of raising an error.
354
- skip_reason = should_skip_for_firebird(stmt)
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
- result = @db.execute(stmt)
360
- if result == false
361
- raise RuntimeError, @db.get_error || "SQL execution failed: #{stmt}"
362
- end
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))