tina4ruby 3.2.1 → 3.10.0

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.
@@ -11,6 +11,11 @@ module Tina4
11
11
  @after_handlers ||= []
12
12
  end
13
13
 
14
+ # Registry of class-based middleware (registered via Router.use)
15
+ def global_middleware
16
+ @global_middleware ||= []
17
+ end
18
+
14
19
  def before(pattern = nil, &block)
15
20
  before_handlers << { pattern: pattern, handler: block }
16
21
  end
@@ -19,26 +24,63 @@ module Tina4
19
24
  after_handlers << { pattern: pattern, handler: block }
20
25
  end
21
26
 
27
+ # Register a class-based middleware globally.
28
+ # The class should define static before_* and/or after_* methods.
29
+ def use(klass)
30
+ global_middleware << klass unless global_middleware.include?(klass)
31
+ end
32
+
22
33
  def clear!
23
34
  @before_handlers = []
24
35
  @after_handlers = []
36
+ @global_middleware = []
25
37
  end
26
38
 
39
+ # Run all "before" hooks: block-based handlers, then class-based before_* methods.
40
+ # Returns [request, response] on success, or false to halt the request.
27
41
  def run_before(request, response)
42
+ # 1. Block-based before handlers (backward compat)
28
43
  before_handlers.each do |entry|
29
44
  next unless matches_pattern?(request.path, entry[:pattern])
30
45
  result = entry[:handler].call(request, response)
31
- # If handler returns false, halt the request
32
46
  return false if result == false
33
47
  end
48
+
49
+ # 2. Class-based middleware: call every before_* method
50
+ global_middleware.each do |klass|
51
+ before_methods_for(klass).each do |method_name|
52
+ result = klass.send(method_name, request, response)
53
+ # Support returning [request, response] (Python convention) or false to halt
54
+ if result == false
55
+ return false
56
+ elsif result.is_a?(Array) && result.length == 2
57
+ request, response = result
58
+ # If response already has a non-2xx status, halt processing
59
+ return false if response.status_code >= 400
60
+ end
61
+ end
62
+ end
63
+
34
64
  true
35
65
  end
36
66
 
67
+ # Run all "after" hooks: block-based handlers, then class-based after_* methods.
37
68
  def run_after(request, response)
69
+ # 1. Block-based after handlers (backward compat)
38
70
  after_handlers.each do |entry|
39
71
  next unless matches_pattern?(request.path, entry[:pattern])
40
72
  entry[:handler].call(request, response)
41
73
  end
74
+
75
+ # 2. Class-based middleware: call every after_* method
76
+ global_middleware.each do |klass|
77
+ after_methods_for(klass).each do |method_name|
78
+ result = klass.send(method_name, request, response)
79
+ if result.is_a?(Array) && result.length == 2
80
+ request, response = result
81
+ end
82
+ end
83
+ end
42
84
  end
43
85
 
44
86
  private
@@ -54,6 +96,312 @@ module Tina4
54
96
  true
55
97
  end
56
98
  end
99
+
100
+ # Collect all public class methods matching before_*
101
+ def before_methods_for(klass)
102
+ klass.methods(false).select { |m| m.to_s.start_with?("before_") }.sort
103
+ end
104
+
105
+ # Collect all public class methods matching after_*
106
+ def after_methods_for(klass)
107
+ klass.methods(false).select { |m| m.to_s.start_with?("after_") }.sort
108
+ end
109
+ end
110
+ end
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Built-in class-based middleware
114
+ # ---------------------------------------------------------------------------
115
+
116
+ # CorsClassMiddleware -- sets CORS headers from env vars on every response.
117
+ # Uses the same config source as CorsMiddleware module.
118
+ class CorsClassMiddleware
119
+ class << self
120
+ def before_cors(request, response)
121
+ config = load_config
122
+ origin = resolve_origin(request, config)
123
+
124
+ response.headers["access-control-allow-origin"] = origin
125
+ response.headers["access-control-allow-methods"] = config[:methods]
126
+ response.headers["access-control-allow-headers"] = config[:headers]
127
+ response.headers["access-control-max-age"] = config[:max_age]
128
+ if config[:credentials] == "true"
129
+ response.headers["access-control-allow-credentials"] = "true"
130
+ end
131
+
132
+ [request, response]
133
+ end
134
+
135
+ private
136
+
137
+ def load_config
138
+ {
139
+ origins: ENV["TINA4_CORS_ORIGINS"] || "*",
140
+ methods: ENV["TINA4_CORS_METHODS"] || "GET, POST, PUT, PATCH, DELETE, OPTIONS",
141
+ headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,Authorization,X-Request-ID",
142
+ max_age: ENV["TINA4_CORS_MAX_AGE"] || "86400",
143
+ credentials: ENV["TINA4_CORS_CREDENTIALS"] || "false"
144
+ }
145
+ end
146
+
147
+ def resolve_origin(request, config)
148
+ request_origin = request.headers["origin"] || request.headers["referer"]
149
+
150
+ if config[:origins] == "*"
151
+ "*"
152
+ elsif request_origin
153
+ allowed = config[:origins].split(",").map(&:strip)
154
+ clean = request_origin.chomp("/")
155
+ allowed.include?(clean) ? clean : allowed.first || "*"
156
+ else
157
+ config[:origins].split(",").first&.strip || "*"
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ # RateLimiterMiddleware -- tracks requests per IP, returns 429 when exceeded.
164
+ # Config via env: TINA4_RATE_LIMIT (default 100), TINA4_RATE_WINDOW (default 60s).
165
+ class RateLimiterMiddleware
166
+ @store = {}
167
+ @mutex = Mutex.new
168
+ @last_cleanup = Time.now
169
+
170
+ class << self
171
+ def before_rate_limit(request, response)
172
+ limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
173
+ window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
174
+ ip = request.ip || "unknown"
175
+ now = Time.now
176
+
177
+ cleanup_if_needed(now, window)
178
+
179
+ @mutex.synchronize do
180
+ @store[ip] ||= []
181
+ entries = @store[ip]
182
+
183
+ # Sliding window -- drop expired timestamps
184
+ cutoff = now - window
185
+ entries.reject! { |t| t < cutoff }
186
+
187
+ if entries.length >= limit
188
+ oldest = entries.first
189
+ retry_after = [(oldest + window - now).ceil, 1].max
190
+
191
+ response.headers["X-RateLimit-Limit"] = limit.to_s
192
+ response.headers["X-RateLimit-Remaining"] = "0"
193
+ response.headers["X-RateLimit-Reset"] = (oldest + window).to_i.to_s
194
+ response.headers["Retry-After"] = retry_after.to_s
195
+ response.json({ error: "Too Many Requests", retry_after: retry_after }, 429)
196
+
197
+ return [request, response]
198
+ end
199
+
200
+ entries << now
201
+
202
+ response.headers["X-RateLimit-Limit"] = limit.to_s
203
+ response.headers["X-RateLimit-Remaining"] = (limit - entries.length).to_s
204
+ response.headers["X-RateLimit-Reset"] = (now + window).to_i.to_s
205
+ end
206
+
207
+ [request, response]
208
+ end
209
+
210
+ # Allow resetting state (useful in tests)
211
+ def reset!
212
+ @mutex.synchronize { @store.clear }
213
+ end
214
+
215
+ private
216
+
217
+ def cleanup_if_needed(now, window)
218
+ return if now - @last_cleanup < window
219
+
220
+ @mutex.synchronize do
221
+ return if now - @last_cleanup < window
222
+
223
+ cutoff = now - window
224
+ @store.delete_if do |_ip, entries|
225
+ entries.reject! { |t| t < cutoff }
226
+ entries.empty?
227
+ end
228
+ @last_cleanup = now
229
+ end
230
+ end
231
+ end
232
+ end
233
+
234
+ # RequestLoggerMiddleware -- logs method, path, and elapsed time for every request.
235
+ class RequestLoggerMiddleware
236
+ @request_times = {}
237
+ @mutex = Mutex.new
238
+
239
+ class << self
240
+ def before_log(request, response)
241
+ request_key = "#{request.object_id}"
242
+ @mutex.synchronize do
243
+ @request_times[request_key] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
244
+ end
245
+ [request, response]
246
+ end
247
+
248
+ def after_log(request, response)
249
+ request_key = "#{request.object_id}"
250
+ start_time = @mutex.synchronize { @request_times.delete(request_key) }
251
+
252
+ if start_time
253
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(3)
254
+ else
255
+ elapsed_ms = 0.0
256
+ end
257
+
258
+ Tina4::Log.info("[RequestLogger] #{request.method} #{request.path} -> #{response.status_code} (#{elapsed_ms}ms)")
259
+ [request, response]
260
+ end
261
+
262
+ def reset!
263
+ @mutex.synchronize { @request_times.clear }
264
+ end
265
+ end
266
+ end
267
+
268
+ # CsrfMiddleware -- validates form tokens on state-changing requests.
269
+ #
270
+ # Off by default -- only active when TINA4_CSRF=true in .env or when
271
+ # registered explicitly via Router.use(CsrfMiddleware).
272
+ #
273
+ # Behaviour:
274
+ # - Skips GET, HEAD, OPTIONS requests.
275
+ # - Skips routes marked .no_auth.
276
+ # - Skips requests with a valid Authorization: Bearer header (API clients).
277
+ # - Checks request.body["formToken"] then request.headers["X-Form-Token"].
278
+ # - Rejects if token found in request.query["formToken"] (log warning, 403).
279
+ # - Validates token with Auth.valid_token using SECRET env var.
280
+ # - If token payload has session_id, verifies it matches request.session.session_id.
281
+ # - Returns 403 with response.json({error: "CSRF_INVALID", message: ...}, 403) on failure.
282
+ class CsrfMiddleware
283
+ class << self
284
+ def before_csrf(request, response)
285
+ # Allow disabling CSRF via env var
286
+ csrf_env = ENV["TINA4_CSRF"].to_s.downcase
287
+ return [request, response] if %w[false 0 no].include?(csrf_env)
288
+
289
+ # Skip safe HTTP methods
290
+ method = (request.method || "GET").upcase
291
+ return [request, response] if %w[GET HEAD OPTIONS].include?(method)
292
+
293
+ # Skip routes marked no_auth
294
+ handler = request.respond_to?(:handler) ? request.handler : nil
295
+ if handler
296
+ no_auth = if handler.is_a?(Hash)
297
+ handler[:no_auth] || handler[:noAuth]
298
+ elsif handler.respond_to?(:no_auth)
299
+ handler.no_auth
300
+ end
301
+ return [request, response] if no_auth
302
+ end
303
+
304
+ # Skip requests with valid Bearer token (API clients)
305
+ headers = request.respond_to?(:headers) ? request.headers : {}
306
+ auth_header = headers["authorization"] || headers["Authorization"] || ""
307
+ if auth_header.start_with?("Bearer ")
308
+ bearer_token = auth_header[7..].strip
309
+ unless bearer_token.empty?
310
+ payload = Tina4::Auth.valid_token(bearer_token)
311
+ return [request, response] if payload
312
+ end
313
+ end
314
+
315
+ # Reject if token is in query string (security risk)
316
+ query = if request.respond_to?(:params)
317
+ request.params
318
+ elsif request.respond_to?(:query)
319
+ request.query
320
+ else
321
+ {}
322
+ end
323
+ query ||= {}
324
+
325
+ if query.is_a?(Hash) && query["formToken"] && !query["formToken"].to_s.empty?
326
+ Tina4::Log.warning("[CSRF] Token found in query string — rejected for security")
327
+ response.json({ error: "CSRF_INVALID", message: "Form token must not be sent in the URL query string" }, 403)
328
+ return [request, response]
329
+ end
330
+
331
+ # Extract token: body first, then header
332
+ token = nil
333
+ body = request.respond_to?(:body) ? request.body : nil
334
+ body ||= {}
335
+ token = body["formToken"] if body.is_a?(Hash)
336
+
337
+ if token.nil? || token.to_s.empty?
338
+ token = headers["X-Form-Token"] || headers["x-form-token"] || ""
339
+ end
340
+
341
+ if token.nil? || token.to_s.empty?
342
+ response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
343
+ return [request, response]
344
+ end
345
+
346
+ # Validate the token
347
+ payload = Tina4::Auth.valid_token(token.to_s)
348
+
349
+ if payload.nil?
350
+ response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
351
+ return [request, response]
352
+ end
353
+
354
+ # Session binding — if token has session_id, verify it matches
355
+ token_session_id = payload["session_id"]
356
+ if token_session_id
357
+ current_session_id = nil
358
+ session = request.respond_to?(:session) ? request.session : nil
359
+ if session
360
+ current_session_id = if session.respond_to?(:session_id)
361
+ session.session_id
362
+ elsif session.is_a?(Hash)
363
+ session["session_id"]
364
+ elsif session.respond_to?(:get)
365
+ session.get("session_id")
366
+ end
367
+ end
368
+
369
+ if current_session_id && token_session_id != current_session_id
370
+ response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
371
+ return [request, response]
372
+ end
373
+ end
374
+
375
+ [request, response]
376
+ end
377
+ end
378
+ end
379
+
380
+ # SecurityHeadersMiddleware -- injects security headers on every response.
381
+ # Config via env:
382
+ # TINA4_FRAME_OPTIONS — X-Frame-Options (default: SAMEORIGIN)
383
+ # TINA4_HSTS — Strict-Transport-Security max-age (default: "" = off)
384
+ # TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
385
+ # TINA4_REFERRER_POLICY — Referrer-Policy (default: strict-origin-when-cross-origin)
386
+ # TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
387
+ class SecurityHeadersMiddleware
388
+ class << self
389
+ def before_security(request, response)
390
+ response.headers["X-Frame-Options"] = ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"
391
+ response.headers["X-Content-Type-Options"] = "nosniff"
392
+
393
+ hsts = ENV["TINA4_HSTS"] || ""
394
+ unless hsts.empty?
395
+ response.headers["Strict-Transport-Security"] = "max-age=#{hsts}; includeSubDomains"
396
+ end
397
+
398
+ response.headers["Content-Security-Policy"] = ENV["TINA4_CSP"] || "default-src 'self'"
399
+ response.headers["Referrer-Policy"] = ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"
400
+ response.headers["X-XSS-Protection"] = "0"
401
+ response.headers["Permissions-Policy"] = ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"
402
+
403
+ [request, response]
404
+ end
57
405
  end
58
406
  end
59
407
  end
@@ -9,7 +9,7 @@ module Tina4
9
9
 
10
10
  def initialize(db, migrations_dir: nil)
11
11
  @db = db
12
- @migrations_dir = migrations_dir || File.join(Dir.pwd, "src", "migrations")
12
+ @migrations_dir = migrations_dir || resolve_migrations_dir
13
13
  ensure_tracking_table
14
14
  end
15
15
 
@@ -89,14 +89,28 @@ module Tina4
89
89
 
90
90
  private
91
91
 
92
+ # Resolve migrations directory: prefer src/migrations, fall back to migrations/
93
+ def resolve_migrations_dir
94
+ src_dir = File.join(Dir.pwd, "src", "migrations")
95
+ return src_dir if Dir.exist?(src_dir)
96
+
97
+ root_dir = File.join(Dir.pwd, "migrations")
98
+ return root_dir if Dir.exist?(root_dir)
99
+
100
+ # Default to src/migrations (will be created when needed)
101
+ src_dir
102
+ end
103
+
92
104
  def ensure_tracking_table
93
105
  unless @db.table_exists?(TRACKING_TABLE)
94
106
  @db.execute(<<~SQL)
95
107
  CREATE TABLE #{TRACKING_TABLE} (
96
108
  id INTEGER PRIMARY KEY,
97
109
  migration_name VARCHAR(255) NOT NULL,
110
+ description VARCHAR(255) DEFAULT '',
98
111
  batch INTEGER NOT NULL DEFAULT 1,
99
- executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
112
+ executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
113
+ passed INTEGER NOT NULL DEFAULT 1
100
114
  )
101
115
  SQL
102
116
  Tina4::Log.info("Created migrations tracking table")
@@ -104,17 +118,17 @@ module Tina4
104
118
  end
105
119
 
106
120
  def completed_migrations
107
- result = @db.fetch("SELECT migration_name FROM #{TRACKING_TABLE} ORDER BY id")
121
+ result = @db.fetch("SELECT migration_name FROM #{TRACKING_TABLE} WHERE passed = 1 ORDER BY id")
108
122
  result.map { |r| r[:migration_name] }
109
123
  end
110
124
 
111
125
  def completed_migrations_with_batch
112
- result = @db.fetch("SELECT id, migration_name, batch FROM #{TRACKING_TABLE} ORDER BY id")
126
+ result = @db.fetch("SELECT id, migration_name, batch FROM #{TRACKING_TABLE} WHERE passed = 1 ORDER BY id")
113
127
  result.map { |r| { id: r[:id], migration_name: r[:migration_name], batch: r[:batch] } }
114
128
  end
115
129
 
116
130
  def next_batch_number
117
- result = @db.fetch_one("SELECT MAX(batch) as max_batch FROM #{TRACKING_TABLE}")
131
+ result = @db.fetch_one("SELECT MAX(batch) as max_batch FROM #{TRACKING_TABLE} WHERE passed = 1")
118
132
  (result && result[:max_batch] ? result[:max_batch].to_i : 0) + 1
119
133
  end
120
134
 
@@ -123,12 +137,24 @@ module Tina4
123
137
 
124
138
  completed = completed_migrations
125
139
  # Support both .rb and .sql migration files
140
+ # Accept both 000001_name.sql (sequential) and YYYYMMDDHHMMSS_name.sql (timestamp) patterns
126
141
  Dir.glob(File.join(@migrations_dir, "*.{rb,sql}"))
127
142
  .reject { |f| f.end_with?(".down.sql") }
128
- .sort
143
+ .sort_by { |f| migration_sort_key(File.basename(f)) }
129
144
  .reject { |f| completed.include?(File.basename(f)) }
130
145
  end
131
146
 
147
+ # Sort key that handles both 000001_name.sql and 20240315120000_name.sql patterns.
148
+ # Both are zero-padded numeric prefixes so alphabetical sorting works, but we
149
+ # extract the prefix explicitly to guarantee correct ordering when mixed.
150
+ def migration_sort_key(filename)
151
+ if filename =~ /\A(\d+)/
152
+ [$1.to_i, filename]
153
+ else
154
+ [0, filename]
155
+ end
156
+ end
157
+
132
158
  def run_migration(file, batch)
133
159
  name = File.basename(file)
134
160
  Tina4::Log.info("Running migration: #{name}")
@@ -138,10 +164,11 @@ module Tina4
138
164
  else
139
165
  execute_sql_file(file)
140
166
  end
141
- record_migration(name, batch)
167
+ record_migration(name, batch, passed: 1)
142
168
  { name: name, status: "success" }
143
169
  rescue => e
144
170
  Tina4::Log.error("Migration failed: #{name} - #{e.message}")
171
+ record_migration(name, batch, passed: 0)
145
172
  { name: name, status: "failed", error: e.message }
146
173
  end
147
174
  end
@@ -181,17 +208,111 @@ module Tina4
181
208
  migration.__send__(direction, @db)
182
209
  end
183
210
 
211
+ # Split SQL into individual statements, handling:
212
+ # - $$ delimited stored procedure blocks
213
+ # - // delimited blocks
214
+ # - Block comments /* ... */
215
+ # - Line comments -- ...
216
+ # Matches the Python/Node.js approach: extract blocks first, split on ;, restore blocks.
217
+ def split_sql_statements(sql, delimiter = ";")
218
+ blocks = []
219
+
220
+ # Extract $$ ... $$ blocks (stored procedures, triggers, etc.)
221
+ processed = sql.gsub(/\$\$(.*?)\$\$/m) do
222
+ blocks << $~.to_s
223
+ "__BLOCK_#{blocks.length - 1}__"
224
+ end
225
+
226
+ # Extract // ... // blocks
227
+ processed = processed.gsub(/\/\/(.*?)\/\//m) do
228
+ blocks << $~.to_s
229
+ "__BLOCK_#{blocks.length - 1}__"
230
+ end
231
+
232
+ # Remove block comments (/* ... */) but not inside stored proc blocks (already extracted)
233
+ clean = processed.gsub(/\/\*.*?\*\//m, "")
234
+
235
+ statements = []
236
+ clean.split(delimiter).each do |stmt|
237
+ lines = []
238
+ stmt.split("\n").each do |line|
239
+ stripped = line.strip
240
+ next if stripped.empty? || stripped.start_with?("--")
241
+ # Remove inline comments (-- after SQL)
242
+ comment_pos = line.index("--")
243
+ line = line[0...comment_pos] if comment_pos && comment_pos >= 0
244
+ lines << line
245
+ end
246
+ cleaned = lines.join("\n").strip
247
+
248
+ # Restore block placeholders
249
+ blocks.each_with_index do |block, i|
250
+ cleaned = cleaned.gsub("__BLOCK_#{i}__", block)
251
+ end
252
+
253
+ statements << cleaned unless cleaned.empty?
254
+ end
255
+
256
+ statements
257
+ end
258
+
184
259
  def execute_sql_file(file)
185
260
  sql = File.read(file)
186
- statements = sql.split(";").map(&:strip).reject(&:empty?)
261
+ statements = split_sql_statements(sql)
187
262
  statements.each do |stmt|
188
- next if stmt.start_with?("--")
263
+ # Firebird lacks IF NOT EXISTS for ALTER TABLE ADD.
264
+ # Pre-check the system catalogue so duplicate columns are
265
+ # silently skipped instead of raising an error.
266
+ skip_reason = should_skip_for_firebird(stmt)
267
+ if skip_reason
268
+ Tina4::Log.info("Migration #{File.basename(file)}: #{skip_reason}")
269
+ next
270
+ end
189
271
  @db.execute(stmt)
190
272
  end
191
273
  end
192
274
 
193
- def record_migration(name, batch)
194
- @db.insert(TRACKING_TABLE, { migration_name: name, batch: batch })
275
+ # Regex to match ALTER TABLE <table> ADD <column> ...
276
+ ALTER_ADD_RE = /\A\s*ALTER\s+TABLE\s+(?:"([^"]+)"|(\S+))\s+ADD\s+(?:"([^"]+)"|(\S+))/i
277
+
278
+ def firebird?
279
+ @db.driver_name == "firebird"
280
+ end
281
+
282
+ # Check if a column already exists in a Firebird table via RDB$RELATION_FIELDS.
283
+ # Firebird stores unquoted identifiers in upper-case.
284
+ def firebird_column_exists?(table, column)
285
+ row = @db.fetch_one(
286
+ "SELECT 1 FROM RDB\$RELATION_FIELDS WHERE RDB\$RELATION_NAME = ? AND TRIM(RDB\$FIELD_NAME) = ?",
287
+ [table.upcase, column.upcase]
288
+ )
289
+ !row.nil?
290
+ end
291
+
292
+ # If stmt is an ALTER TABLE ... ADD on Firebird and the column already exists,
293
+ # returns a skip reason. Returns nil if the statement should execute normally.
294
+ def should_skip_for_firebird(stmt)
295
+ return nil unless firebird?
296
+
297
+ m = stmt.match(ALTER_ADD_RE)
298
+ return nil unless m
299
+
300
+ table = m[1] || m[2]
301
+ column = m[3] || m[4]
302
+
303
+ if firebird_column_exists?(table, column)
304
+ "Column #{column} already exists in #{table}, skipping"
305
+ end
306
+ end
307
+
308
+ def record_migration(name, batch, passed: 1)
309
+ # Extract description from filename (strip numeric prefix and extension)
310
+ stem = File.basename(name, File.extname(name))
311
+ desc = stem.sub(/\A\d+_/, "").tr("_", " ")
312
+ @db.execute(
313
+ "INSERT INTO #{TRACKING_TABLE} (migration_name, description, batch, passed) VALUES (?, ?, ?, ?)",
314
+ [name, desc, batch, passed]
315
+ )
195
316
  end
196
317
 
197
318
  def remove_migration_record(name)
data/lib/tina4/orm.rb CHANGED
@@ -85,6 +85,16 @@ module Tina4
85
85
  end
86
86
  end
87
87
 
88
+ # Create a fluent QueryBuilder pre-configured for this model's table and database.
89
+ #
90
+ # Usage:
91
+ # results = User.query.where("active = ?", [1]).order_by("name").get
92
+ #
93
+ # @return [Tina4::QueryBuilder]
94
+ def query
95
+ QueryBuilder.from(table_name, db: db)
96
+ end
97
+
88
98
  def find(id_or_filter = nil, filter = nil, **kwargs)
89
99
  include_list = kwargs.delete(:include)
90
100
 
@@ -199,21 +209,20 @@ module Tina4
199
209
  instances
200
210
  end
201
211
 
202
- def all(limit: nil, offset: nil, skip: nil, order_by: nil, include: nil)
212
+ def all(limit: nil, offset: nil, order_by: nil, include: nil)
203
213
  sql = "SELECT * FROM #{table_name}"
204
214
  if soft_delete
205
215
  sql += " WHERE #{soft_delete_field} IS NULL OR #{soft_delete_field} = 0"
206
216
  end
207
217
  sql += " ORDER BY #{order_by}" if order_by
208
- effective_offset = offset || skip
209
- results = db.fetch(sql, [], limit: limit, skip: effective_offset)
218
+ results = db.fetch(sql, [], limit: limit, offset: offset)
210
219
  instances = results.map { |row| from_hash(row) }
211
220
  eager_load(instances, include) if include
212
221
  instances
213
222
  end
214
223
 
215
- def select(sql, params = [], limit: nil, skip: nil, include: nil)
216
- results = db.fetch(sql, params, limit: limit, skip: skip)
224
+ def select(sql, params = [], limit: nil, offset: nil, include: nil)
225
+ results = db.fetch(sql, params, limit: limit, offset: offset)
217
226
  instances = results.map { |row| from_hash(row) }
218
227
  eager_load(instances, include) if include
219
228
  instances
@@ -243,9 +252,9 @@ module Tina4
243
252
  result
244
253
  end
245
254
 
246
- def with_trashed(conditions = "1=1", params = [], limit: 20, skip: 0)
255
+ def with_trashed(conditions = "1=1", params = [], limit: 20, offset: 0)
247
256
  sql = "SELECT * FROM #{table_name} WHERE #{conditions}"
248
- results = db.fetch(sql, params, limit: limit, skip: skip)
257
+ results = db.fetch(sql, params, limit: limit, offset: offset)
249
258
  results.map { |row| from_hash(row) }
250
259
  end
251
260
 
@@ -290,7 +299,7 @@ module Tina4
290
299
  end
291
300
 
292
301
  def scope(name, filter_sql, params = [])
293
- define_singleton_method(name) do |limit: 20, skip: 0|
302
+ define_singleton_method(name) do |limit: 20, offset: 0|
294
303
  where(filter_sql, params)
295
304
  end
296
305
  end
@@ -21,7 +21,7 @@ document.getElementById('routes-count').textContent = d.count;
21
21
  document.getElementById('routes-body').innerHTML = d.routes.map(r => `
22
22
  <tr>
23
23
  <td><span class="method method-${r.method.toLowerCase()}">${r.method}</span></td>
24
- <td class="path">${r.path || r.pattern || ''}</td>
24
+ <td class="path"><a href="${r.path || r.pattern || ''}" target="_blank" title="${r.method !== 'GET' ? r.method + ' route — may not respond to browser GET' : 'Open in new tab'}" style="color:inherit;text-decoration:underline dotted;${r.method !== 'GET' ? 'opacity:0.7' : ''}">${r.path || r.pattern || ''}</a></td>
25
25
  <td>${r.auth_required || r.secure ? '<span class="badge-pill bg-reserved">auth</span>' : '<span class="badge-pill bg-success">open</span>'}</td>
26
26
  <td class="text-sm text-muted">${r.handler || ''} ${r.module ? '<small>(' + r.module + ')</small>' : ''}</td>
27
27
  </tr>`).join('');