rodauth-tools 0.3.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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +93 -0
  3. data/.gitlint +9 -0
  4. data/.markdownlint-cli2.jsonc +26 -0
  5. data/.pre-commit-config.yaml +46 -0
  6. data/.rubocop.yml +18 -0
  7. data/.rubocop_todo.yml +243 -0
  8. data/CHANGELOG.md +81 -0
  9. data/CLAUDE.md +262 -0
  10. data/CODE_OF_CONDUCT.md +132 -0
  11. data/CONTRIBUTING.md +111 -0
  12. data/Gemfile +35 -0
  13. data/Gemfile.lock +356 -0
  14. data/LICENSE.txt +21 -0
  15. data/README.md +339 -0
  16. data/Rakefile +8 -0
  17. data/lib/rodauth/features/external_identity.rb +946 -0
  18. data/lib/rodauth/features/hmac_secret_guard.rb +119 -0
  19. data/lib/rodauth/features/jwt_secret_guard.rb +120 -0
  20. data/lib/rodauth/features/table_guard.rb +937 -0
  21. data/lib/rodauth/sequel_generator.rb +531 -0
  22. data/lib/rodauth/table_inspector.rb +124 -0
  23. data/lib/rodauth/template_inspector.rb +134 -0
  24. data/lib/rodauth/tools/console_helpers.rb +158 -0
  25. data/lib/rodauth/tools/migration/sequel/account_expiration.erb +9 -0
  26. data/lib/rodauth/tools/migration/sequel/active_sessions.erb +10 -0
  27. data/lib/rodauth/tools/migration/sequel/audit_logging.erb +12 -0
  28. data/lib/rodauth/tools/migration/sequel/base.erb +41 -0
  29. data/lib/rodauth/tools/migration/sequel/disallow_password_reuse.erb +8 -0
  30. data/lib/rodauth/tools/migration/sequel/email_auth.erb +17 -0
  31. data/lib/rodauth/tools/migration/sequel/jwt_refresh.erb +18 -0
  32. data/lib/rodauth/tools/migration/sequel/lockout.erb +21 -0
  33. data/lib/rodauth/tools/migration/sequel/otp.erb +9 -0
  34. data/lib/rodauth/tools/migration/sequel/otp_unlock.erb +8 -0
  35. data/lib/rodauth/tools/migration/sequel/password_expiration.erb +7 -0
  36. data/lib/rodauth/tools/migration/sequel/recovery_codes.erb +8 -0
  37. data/lib/rodauth/tools/migration/sequel/remember.erb +16 -0
  38. data/lib/rodauth/tools/migration/sequel/reset_password.erb +17 -0
  39. data/lib/rodauth/tools/migration/sequel/single_session.erb +7 -0
  40. data/lib/rodauth/tools/migration/sequel/sms_codes.erb +10 -0
  41. data/lib/rodauth/tools/migration/sequel/verify_account.erb +9 -0
  42. data/lib/rodauth/tools/migration/sequel/verify_login_change.erb +17 -0
  43. data/lib/rodauth/tools/migration/sequel/webauthn.erb +15 -0
  44. data/lib/rodauth/tools/migration.rb +188 -0
  45. data/lib/rodauth/tools/version.rb +9 -0
  46. data/lib/rodauth/tools.rb +29 -0
  47. data/package-lock.json +500 -0
  48. data/package.json +11 -0
  49. data/rodauth-tools.gemspec +40 -0
  50. metadata +136 -0
@@ -0,0 +1,937 @@
1
+ # lib/rodauth/features/table_guard.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # Ensure dependencies are loaded (they should be via require 'rodauth/tools')
6
+ require_relative '../table_inspector' unless defined?(Rodauth::TableInspector)
7
+ require_relative '../sequel_generator' unless defined?(Rodauth::SequelGenerator)
8
+
9
+ #
10
+ # Enable with:
11
+ # enable :table_guard
12
+ #
13
+ # Configuration:
14
+ # table_guard_mode :warn # :warn, :error, :silent, :skip, :raise, :halt/:exit, or block
15
+ # table_guard_sequel_mode :log # :log, :migration, :create, :sync
16
+ # table_guard_skip_tables [:some_table] # Skip checking specific tables
17
+ #
18
+ # Logging:
19
+ # def logger; MyLogger; end # Standard Rodauth logger (recommended)
20
+ # table_guard_logger MyLogger # Feature-specific logger (alternative)
21
+ #
22
+ # Example modes:
23
+ #
24
+ # # Warn but continue
25
+ # table_guard_mode :warn
26
+ #
27
+ # # Error log but continue
28
+ # table_guard_mode :error
29
+ #
30
+ # # Raise exception for handling upstream
31
+ # table_guard_mode :raise
32
+ #
33
+ # # Halt/exit startup (not recommended for multi-tenant)
34
+ # table_guard_mode :halt
35
+ #
36
+ # # Custom handling with block
37
+ # table_guard_mode do |missing, config|
38
+ # TenantLogger.log_missing_tables(current_tenant, missing)
39
+ # end
40
+ #
41
+ # Sequel generation modes:
42
+ #
43
+ # # Log migration code to logger
44
+ # table_guard_sequel_mode :log
45
+ #
46
+ # # Generate migration file
47
+ # table_guard_sequel_mode :migration
48
+ #
49
+ # # Create tables immediately (JIT)
50
+ # table_guard_sequel_mode :create
51
+ #
52
+ # # Drop and recreate missing tables (dev/test only)
53
+ # table_guard_sequel_mode :sync
54
+ #
55
+ # # Drop and recreate ALL tables every startup (dev/test only)
56
+ # table_guard_sequel_mode :recreate
57
+
58
+ module Rodauth
59
+ Feature.define(:table_guard, :TableGuard) do
60
+ # Configuration methods
61
+ auth_value_method :table_guard_mode, nil
62
+ auth_value_method :table_guard_sequel_mode, nil
63
+ auth_value_method :table_guard_skip_tables, []
64
+ auth_value_method :table_guard_check_columns?, true
65
+ auth_value_method :table_guard_migration_path, 'db/migrate'
66
+ auth_value_method :table_guard_logger, nil
67
+ auth_value_method :table_guard_logger_name, nil # For SemanticLogger integration
68
+
69
+ # Public API methods
70
+ auth_methods(
71
+ :check_required_tables!,
72
+ :missing_tables,
73
+ :missing_columns,
74
+ :all_table_methods,
75
+ :list_all_required_tables,
76
+ :list_all_required_columns,
77
+ :table_status,
78
+ :column_status,
79
+ :register_required_column
80
+ )
81
+
82
+ # Use auth_cached_method for table_configuration so it's computed
83
+ # lazily per-instance and cached. This ensures it works in both:
84
+ # - Normal web request flow (post_configure runs on throwaway instance)
85
+ # - Console interrogation (new instances need access to configuration)
86
+ auth_cached_method :table_configuration
87
+
88
+ # Use auth_cached_method for column_requirements so it persists across instances
89
+ # Column requirements are registered dynamically by features like external_identity
90
+ auth_cached_method :column_requirements
91
+
92
+ # Runs after configuration is complete
93
+ #
94
+ # Checks tables based on mode. Note: post_configure runs on a throwaway
95
+ # instance during initial configuration (see Rodauth.configure line 66),
96
+ # so we can't rely on instance variables persisting. Use auth_cached_method
97
+ # for any data needed by later instances.
98
+ def post_configure
99
+ super if defined?(super)
100
+
101
+ # Check tables based on mode (uses lazy-loaded table_configuration)
102
+ # Always check if sequel_mode is set (even in silent mode), since sequel_mode
103
+ # indicates we want to create/generate even if we don't want validation messages
104
+ check_required_tables! if should_check_tables? || table_guard_sequel_mode
105
+ end
106
+
107
+ # Override hook_action to check table status
108
+ #
109
+ # [Reviewer note] Do not remove this method even though it does nothing by default.
110
+ #
111
+ # @param [Symbol] hook_type :before or :after
112
+ # @param [Symbol] action :login, :logout, etc.
113
+ def hook_action(hook_type, action)
114
+ super # does nothing by default
115
+ end
116
+
117
+ # Determine if table checking should run
118
+ #
119
+ # Returns true unless mode is :skip, :silent, or nil
120
+ def should_check_tables?
121
+ # Check if table_guard_mode is defined as a block (has parameters)
122
+ # by checking the method's arity. If arity > 0, it's a block that
123
+ # expects arguments and we can't call it without args.
124
+ mode_method = method(:table_guard_mode)
125
+
126
+ # If method expects parameters (arity > 0), it's a custom block handler
127
+ return true if mode_method.arity > 0
128
+
129
+ # Safe to call the method - it either returns a symbol or is a 0-arity block
130
+ mode_value = table_guard_mode
131
+
132
+ # Always check if mode is a Proc (0-arity custom handler)
133
+ return true if mode_value.is_a?(Proc)
134
+
135
+ # Check if mode indicates checking is enabled
136
+ mode_value != :silent && mode_value != :skip && !mode_value.nil?
137
+ end
138
+
139
+ # Internal method called by auth_cached_method :table_configuration
140
+ #
141
+ # Discovers and returns table configuration. This is called lazily
142
+ # per-instance and the result is cached in @table_configuration.
143
+ #
144
+ # @return [Hash<Symbol, Hash>] Table configuration
145
+ def _table_configuration
146
+ config = Rodauth::TableInspector.table_information(self)
147
+ rodauth_debug("[table_guard] Discovered #{config.size} required tables") if ENV['RODAUTH_DEBUG']
148
+ config
149
+ end
150
+
151
+ # Internal method called by auth_cached_method :column_requirements
152
+ #
153
+ # Initializes and returns column requirements hash. This is called lazily
154
+ # per-instance and the result is cached in @column_requirements.
155
+ #
156
+ # Structure: { table_name => { column_name => { type:, null:, feature: } } }
157
+ #
158
+ # @return [Hash<Symbol, Hash<Symbol, Hash>>] Column requirements by table
159
+ def _column_requirements
160
+ {}
161
+ end
162
+
163
+ # Check required tables and handle based on mode
164
+ #
165
+ # This is the main entry point for table validation
166
+ def check_required_tables!
167
+ missing = missing_tables
168
+ missing_cols = missing_columns
169
+
170
+ # Special case: :recreate and :drop modes always run, even when no tables are missing
171
+ if missing.empty? && missing_cols.empty? && !%i[recreate drop].include?(table_guard_sequel_mode)
172
+ rodauth_info('')
173
+ rodauth_info('─' * 50)
174
+ rodauth_info('✅ TableGuard: All required tables and columns exist')
175
+ rodauth_info(" #{table_configuration.size} tables validated successfully")
176
+ if list_all_required_columns.any?
177
+ rodauth_info(" #{list_all_required_columns.size} columns validated successfully")
178
+ end
179
+ rodauth_info('─' * 50)
180
+ rodauth_info('')
181
+ return
182
+ end
183
+
184
+ # Handle based on validation mode (unless recreate/drop mode which handles its own validation)
185
+ handle_table_guard_mode(missing) unless %i[recreate drop].include?(table_guard_sequel_mode)
186
+
187
+ # Handle missing columns separately if validation mode passes
188
+ handle_column_guard_mode(missing_cols) if missing_cols.any? && !%i[recreate
189
+ drop].include?(table_guard_sequel_mode)
190
+
191
+ # Generate Sequel if configured
192
+ handle_sequel_generation(missing, missing_cols) if table_guard_sequel_mode
193
+ end
194
+
195
+ # Get list of tables that are missing
196
+ #
197
+ # @return [Array<Hash>] Array of missing table information
198
+ def missing_tables
199
+ result = []
200
+
201
+ table_configuration.each do |method, info|
202
+ table_name = info[:name]
203
+ next if table_exists?(table_name)
204
+
205
+ result << {
206
+ method: method,
207
+ table: table_name,
208
+ feature: info[:feature],
209
+ structure: info[:structure]
210
+ }
211
+ end
212
+
213
+ result
214
+ end
215
+
216
+ # Get all table method names ending in _table
217
+ #
218
+ # @return [Array<Symbol>] Table method names
219
+ def all_table_methods
220
+ methods.select { |m| m.to_s.end_with?('_table') }
221
+ end
222
+
223
+ # Check if a table exists in the database
224
+ #
225
+ # Temporarily suppresses Sequel's logger to avoid confusing error logs
226
+ # when checking non-existent tables (Sequel logs SQLite exceptions before
227
+ # catching them internally).
228
+ #
229
+ # @param table_name [String, Symbol] Table name
230
+ # @return [Boolean] True if table exists
231
+ def table_exists?(table_name)
232
+ return true if table_guard_skip_tables.include?(table_name.to_sym) ||
233
+ table_guard_skip_tables.include?(table_name.to_s)
234
+
235
+ # Temporarily suppress Sequel's logger to prevent confusing error logs
236
+ # during table existence checks. Sequel's table_exists? implementation
237
+ # attempts a SELECT query and logs the exception if table doesn't exist,
238
+ # even though it catches the error internally.
239
+ original_logger = db.loggers.dup
240
+ db.loggers.clear
241
+
242
+ db.table_exists?(table_name)
243
+ rescue StandardError => e
244
+ rodauth_warn("[table_guard] Unable to check table existence for #{table_name}: #{e.message}")
245
+ true # Assume exists to avoid false positives
246
+ ensure
247
+ # Restore original loggers
248
+ if original_logger
249
+ db.loggers.clear
250
+ original_logger.each { |logger| db.loggers << logger }
251
+ end
252
+ end
253
+
254
+ # List all required table names (sorted)
255
+ #
256
+ # @return [Array<String>] Sorted table names
257
+ def list_all_required_tables
258
+ table_configuration.values.map { |info| info[:name] }.uniq.sort
259
+ end
260
+
261
+ # Get detailed status for all tables
262
+ #
263
+ # @return [Array<Hash>] Status information for each table
264
+ def table_status
265
+ table_configuration.map do |method, info|
266
+ {
267
+ method: method,
268
+ table: info[:name],
269
+ feature: info[:feature],
270
+ exists: table_exists?(info[:name])
271
+ }
272
+ end
273
+ end
274
+
275
+ # Register a required column for validation and generation
276
+ #
277
+ # This method allows features like external_identity to register
278
+ # column requirements that should be validated and optionally
279
+ # created via ALTER TABLE statements.
280
+ #
281
+ # @param table_name [Symbol] Table name (e.g., :accounts)
282
+ # @param column_def [Hash] Column definition with keys:
283
+ # - :name [Symbol] Column name (required)
284
+ # - :type [Symbol] Column type (default: :String)
285
+ # - :null [Boolean] Allow NULL (default: true)
286
+ # - :default [Object] Default value (optional)
287
+ # - :unique [Boolean] Unique constraint (default: false)
288
+ # - :size [Integer] Column size for strings (optional)
289
+ # - :index [Boolean, Hash] Create index (default: false)
290
+ # - :feature [Symbol] Feature that requires this column (default: :unknown)
291
+ #
292
+ # @example
293
+ # register_required_column(:accounts, {
294
+ # name: :stripe_customer_id,
295
+ # type: :String,
296
+ # null: true,
297
+ # unique: true,
298
+ # index: true,
299
+ # feature: :external_identity
300
+ # })
301
+ def register_required_column(table_name, column_def)
302
+ table_name = table_name.to_sym
303
+ column_name = column_def[:name].to_sym
304
+
305
+ # Initialize table entry if needed
306
+ column_requirements[table_name] ||= {}
307
+
308
+ # Store column definition with all Sequel options
309
+ column_requirements[table_name][column_name] = {
310
+ type: column_def[:type] || :String,
311
+ null: column_def.fetch(:null, true),
312
+ default: column_def[:default],
313
+ unique: column_def[:unique],
314
+ size: column_def[:size],
315
+ index: column_def[:index],
316
+ feature: column_def[:feature] || :unknown
317
+ }
318
+
319
+ rodauth_debug("[table_guard] Registered required column #{table_name}.#{column_name} (#{column_def[:feature]})")
320
+ end
321
+
322
+ # Get missing columns across all registered requirements
323
+ #
324
+ # @return [Array<Hash>] Array of missing column information
325
+ def missing_columns
326
+ result = []
327
+
328
+ column_requirements.each do |table_name, columns|
329
+ # Skip if table doesn't exist yet
330
+ next unless table_exists?(table_name)
331
+
332
+ # Get actual columns from database
333
+ actual_columns = db.schema(table_name).map { |col| col[0] }
334
+
335
+ # Check each required column
336
+ columns.each do |column_name, column_def|
337
+ next if actual_columns.include?(column_name)
338
+
339
+ result << {
340
+ table: table_name,
341
+ column: column_name,
342
+ type: column_def[:type],
343
+ null: column_def[:null],
344
+ default: column_def[:default],
345
+ unique: column_def[:unique],
346
+ size: column_def[:size],
347
+ index: column_def[:index],
348
+ feature: column_def[:feature]
349
+ }
350
+ end
351
+ end
352
+
353
+ result
354
+ end
355
+
356
+ # List all required columns
357
+ #
358
+ # @return [Array<Hash>] Array of column requirements
359
+ def list_all_required_columns
360
+ result = []
361
+
362
+ column_requirements.each do |table_name, columns|
363
+ columns.each do |column_name, column_def|
364
+ result << {
365
+ table: table_name,
366
+ column: column_name,
367
+ type: column_def[:type],
368
+ null: column_def[:null],
369
+ default: column_def[:default],
370
+ unique: column_def[:unique],
371
+ size: column_def[:size],
372
+ index: column_def[:index],
373
+ feature: column_def[:feature]
374
+ }
375
+ end
376
+ end
377
+
378
+ result.sort_by { |col| [col[:table].to_s, col[:column].to_s] }
379
+ end
380
+
381
+ # Get detailed status for all columns
382
+ #
383
+ # @return [Array<Hash>] Status information for each column
384
+ def column_status
385
+ result = []
386
+
387
+ column_requirements.each do |table_name, columns|
388
+ # Get actual columns if table exists
389
+ actual_columns = if table_exists?(table_name)
390
+ db.schema(table_name).map { |col| col[0] }
391
+ else
392
+ []
393
+ end
394
+
395
+ columns.each do |column_name, column_def|
396
+ result << {
397
+ table: table_name,
398
+ column: column_name,
399
+ type: column_def[:type],
400
+ null: column_def[:null],
401
+ default: column_def[:default],
402
+ unique: column_def[:unique],
403
+ size: column_def[:size],
404
+ index: column_def[:index],
405
+ feature: column_def[:feature],
406
+ exists: actual_columns.include?(column_name),
407
+ table_exists: table_exists?(table_name)
408
+ }
409
+ end
410
+ end
411
+
412
+ result
413
+ end
414
+
415
+ private
416
+
417
+ # Handle column validation based on mode setting
418
+ #
419
+ # @param missing_cols [Array<Hash>] Missing column information
420
+ def handle_column_guard_mode(missing_cols)
421
+ return if missing_cols.empty?
422
+
423
+ # Check if table_guard_mode is a block by inspecting method arity
424
+ mode_method = method(:table_guard_mode)
425
+
426
+ # If method expects parameters, it's a custom block handler
427
+ if mode_method.arity > 0
428
+ # Call with appropriate arguments based on arity
429
+ result = case mode_method.arity
430
+ when 1 then table_guard_mode(missing_cols)
431
+ else table_guard_mode(missing_cols, column_requirements)
432
+ end
433
+
434
+ case result
435
+ when :error, :raise, true
436
+ raise Rodauth::ConfigurationError, build_missing_columns_message(missing_cols)
437
+ when String
438
+ raise Rodauth::ConfigurationError, result
439
+ # :continue, nil, false means don't raise
440
+ end
441
+ return
442
+ end
443
+
444
+ # Safe to call without arguments - get the mode value
445
+ mode = table_guard_mode
446
+
447
+ # If it's a 0-arity Proc, call it
448
+ if mode.is_a?(Proc)
449
+ result = mode.call
450
+
451
+ case result
452
+ when :error, :raise, true
453
+ raise Rodauth::ConfigurationError, build_missing_columns_message(missing_cols)
454
+ when String
455
+ raise Rodauth::ConfigurationError, result
456
+ # :continue, nil, false means don't raise
457
+ end
458
+ return
459
+ end
460
+
461
+ # Handle symbol modes
462
+ case mode
463
+ when :silent, :skip, nil
464
+ rodauth_debug("[table_guard] Discovered #{list_all_required_columns.size} columns, skipping validation")
465
+
466
+ when :warn
467
+ rodauth_warn(build_missing_columns_message(missing_cols))
468
+
469
+ when :error
470
+ # Print distinctive message to error log but continue execution
471
+ rodauth_error(build_missing_columns_error(missing_cols))
472
+
473
+ when :raise
474
+ # Let the error propagate up
475
+ rodauth_error(build_missing_columns_error(missing_cols))
476
+ raise Rodauth::ConfigurationError, build_missing_columns_message(missing_cols)
477
+
478
+ when :halt, :exit
479
+ # Exit the process early
480
+ rodauth_error(build_missing_columns_error(missing_cols))
481
+ exit(1)
482
+
483
+ else
484
+ raise Rodauth::ConfigurationError,
485
+ "Invalid table_guard_mode: #{mode.inspect}. " \
486
+ 'Expected :silent, :skip, :warn, :error, :raise, :halt, or a Proc.'
487
+ end
488
+ end
489
+
490
+ # Handle table validation based on mode setting
491
+ #
492
+ # @param missing [Array<Hash>] Missing table information
493
+ def handle_table_guard_mode(missing)
494
+ # Check if table_guard_mode is a block by inspecting method arity
495
+ mode_method = method(:table_guard_mode)
496
+
497
+ # If method expects parameters, it's a custom block handler
498
+ if mode_method.arity > 0
499
+ # Call with appropriate arguments based on arity
500
+ result = case mode_method.arity
501
+ when 1 then table_guard_mode(missing)
502
+ else table_guard_mode(missing, table_configuration)
503
+ end
504
+
505
+ case result
506
+ when :error, :raise, true
507
+ raise Rodauth::ConfigurationError, build_missing_tables_message(missing)
508
+ when String
509
+ raise Rodauth::ConfigurationError, result
510
+ # :continue, nil, false means don't raise
511
+ end
512
+ return
513
+ end
514
+
515
+ # Safe to call without arguments - get the mode value
516
+ mode = table_guard_mode
517
+
518
+ # If it's a 0-arity Proc, call it
519
+ if mode.is_a?(Proc)
520
+ result = mode.call
521
+
522
+ case result
523
+ when :error, :raise, true
524
+ raise Rodauth::ConfigurationError, build_missing_tables_message(missing)
525
+ when String
526
+ raise Rodauth::ConfigurationError, result
527
+ # :continue, nil, false means don't raise
528
+ end
529
+ return
530
+ end
531
+
532
+ # Handle symbol modes
533
+ case mode
534
+ when :silent, :skip, nil
535
+ rodauth_debug("[table_guard] Discovered #{@table_configuration.size} tables, skipping validation")
536
+
537
+ when :warn
538
+ rodauth_warn(build_missing_tables_message(missing))
539
+
540
+ when :error
541
+ # Print distinctive message to error log but continue execution
542
+ rodauth_error(build_missing_tables_error(missing))
543
+
544
+ when :raise
545
+ # Let the error propagate up
546
+ rodauth_error(build_missing_tables_error(missing))
547
+ raise Rodauth::ConfigurationError, build_missing_tables_message(missing)
548
+
549
+ when :halt, :exit
550
+ # Exit the process early
551
+ rodauth_error(build_missing_tables_error(missing))
552
+ exit(1)
553
+
554
+ else
555
+ raise Rodauth::ConfigurationError,
556
+ "Invalid table_guard_mode: #{mode.inspect}. " \
557
+ 'Expected :silent, :skip, :warn, :error, :raise, :halt, or a Proc.'
558
+ end
559
+ end
560
+
561
+ # Handle Sequel generation based on sequel mode
562
+ #
563
+ # @param missing [Array<Hash>] Missing table information
564
+ # @param missing_cols [Array<Hash>] Missing column information
565
+ def handle_sequel_generation(missing, missing_cols = [])
566
+ generator = Rodauth::SequelGenerator.new(missing, self, missing_cols)
567
+
568
+ case table_guard_sequel_mode
569
+ when :log
570
+ rodauth_info("[table_guard] Sequel migration code:\n\n#{generator.generate_migration}")
571
+
572
+ when :migration
573
+ filename = generate_migration_filename
574
+ FileUtils.mkdir_p(File.dirname(filename))
575
+ File.write(filename, generator.generate_migration)
576
+ rodauth_info("[table_guard] Generated migration file: #{filename}")
577
+
578
+ when :create
579
+ rodauth_debug("[table_guard] Creating #{missing.size} table(s)...")
580
+ generator.execute_creates(db)
581
+ rodauth_info("[table_guard] Created #{missing.size} table(s)")
582
+
583
+ # Re-validate to show success message
584
+ revalidate_after_creation
585
+
586
+ when :sync
587
+ unless %w[dev development test].any? { |env| ENV['RACK_ENV']&.start_with?(env) }
588
+ rodauth_error("[table_guard] :sync mode only available in dev/test environments (current: #{ENV.fetch(
589
+ "RACK_ENV", nil
590
+ )})")
591
+ return
592
+ end
593
+
594
+ # Drop and recreate only missing tables
595
+ rodauth_info("[table_guard] Syncing #{missing.size} table(s)...")
596
+ generator.execute_drops(db)
597
+ generator.execute_creates(db)
598
+ rodauth_info("[table_guard] Synced #{missing.size} table(s) (dropped and recreated)")
599
+
600
+ # Re-validate to show success message
601
+ revalidate_after_creation
602
+
603
+ when :recreate
604
+ unless %w[dev development test].any? { |env| ENV['RACK_ENV']&.start_with?(env) }
605
+ rodauth_error("[table_guard] :recreate mode only available in dev/test environments (current: #{ENV.fetch(
606
+ "RACK_ENV", nil
607
+ )})")
608
+ return
609
+ end
610
+
611
+ # Get all required tables from configuration
612
+ all_tables = table_configuration.map { |_, info| info[:name] }.uniq
613
+
614
+ # Drop all existing tables in reverse dependency order
615
+ rodauth_info("[table_guard] Recreating #{all_tables.size} table(s) (dropping all, creating fresh)...")
616
+ drop_tables(all_tables.reverse)
617
+
618
+ # Create all tables fresh (uses missing_tables which should now be all of them)
619
+ current_missing = missing_tables
620
+ current_missing_cols = missing_columns
621
+ if current_missing.any? || current_missing_cols.any?
622
+ generator_for_all = Rodauth::SequelGenerator.new(current_missing, self, current_missing_cols)
623
+ generator_for_all.execute_creates(db)
624
+ end
625
+
626
+ rodauth_info("[table_guard] Recreated #{all_tables.size} table(s)")
627
+
628
+ # Re-validate to show success message
629
+ revalidate_after_creation
630
+
631
+ # This is useful when you already have auto migrations that run at start
632
+ # time. This will drop the tables so that the migrations run every time.
633
+ when :drop
634
+ unless %w[dev development test].any? { |env| ENV['RACK_ENV']&.start_with?(env) }
635
+ rodauth_error("[table_guard] :drop mode only available in dev/test environments (current: #{ENV.fetch(
636
+ "RACK_ENV", nil
637
+ )})")
638
+ return
639
+ end
640
+
641
+ # Get all required tables from configuration
642
+ all_tables = table_configuration.map { |_, info| info[:name] }.uniq
643
+
644
+ # Drop all existing tables in reverse dependency order
645
+ rodauth_info("[table_guard] Dropping #{all_tables.size} table(s)...")
646
+ drop_tables(all_tables.reverse)
647
+
648
+ # Drop Sequel migration tracking tables so migrations re-run from scratch
649
+ drop_tables(%i[schema_info schema_migrations])
650
+
651
+ rodauth_info("[table_guard] Dropped #{all_tables.size} table(s) and migration tracking")
652
+ rodauth_info('[table_guard] Migrations will run from scratch on next execution')
653
+
654
+ else
655
+ rodauth_error("[table_guard] Invalid sequel mode: #{table_guard_sequel_mode.inspect}")
656
+ end
657
+ rescue StandardError => e
658
+ rodauth_error("[table_guard] Sequel generation failed: #{e.class} - #{e.message}")
659
+ rodauth_error(" Location: #{e.backtrace.first}")
660
+ raise if %i[raise halt exit].include?(table_guard_mode)
661
+ end
662
+
663
+ # Check if the database supports CASCADE on DELETE
664
+ #
665
+ # @return [Boolean] True if using a db engine that supports DELETE ... CASCADE
666
+ def cascade_supported?
667
+ %i[postgres mysql].include?(db.database_type)
668
+ end
669
+
670
+ # Drop tables with proper CASCADE handling for non-SQLite databases
671
+ #
672
+ # SQLite doesn't support CASCADE on DROP TABLE, so we need to detect
673
+ # the database type and avoid using it. For other databases, CASCADE
674
+ # ensures dependent objects are properly cleaned up.
675
+ #
676
+ # @param table_names [Array<String, Symbol>] Tables to drop
677
+ def drop_tables(table_names)
678
+ table_names.each do |table_name|
679
+ next unless db.table_exists?(table_name)
680
+
681
+ # SQLite: simple drop without CASCADE
682
+ # PostgreSQL, MySQL: use CASCADE for proper cleanup
683
+ options = {
684
+ cascade: cascade_supported?
685
+ }
686
+ db.drop_table(table_name, **options)
687
+
688
+ rodauth_debug("[table_guard] Dropped #{table_name} (#{options})") if ENV['RODAUTH_DEBUG']
689
+ end
690
+ end
691
+
692
+ # Re-validate tables after creation to show success message
693
+ #
694
+ # This runs the validation again after tables are created,
695
+ # which will display the success message instead of leaving
696
+ # the error/warning messages as the last output
697
+ def revalidate_after_creation
698
+ rodauth_info('') # Blank line for readability
699
+
700
+ still_missing = missing_tables
701
+
702
+ if still_missing.empty?
703
+ rodauth_info('=' * 70)
704
+ rodauth_info('✓ [table_guard] All required tables now exist')
705
+ rodauth_info(" #{table_configuration.size} tables validated successfully")
706
+ rodauth_info('=' * 70)
707
+ else
708
+ rodauth_error("[table_guard] Still missing #{still_missing.size} table(s) after creation!")
709
+ still_missing.each do |info|
710
+ rodauth_error(" - #{info[:table]} (#{info[:feature]})")
711
+ end
712
+ end
713
+ end
714
+
715
+ # Generate migration filename with timestamp
716
+ #
717
+ # @return [String] Full path to migration file
718
+ def generate_migration_filename
719
+ timestamp = Time.now.strftime('%Y%m%d%H%M%S')
720
+ filename = "#{timestamp}_create_rodauth_tables.rb"
721
+ File.join(table_guard_migration_path, filename)
722
+ end
723
+
724
+ # Build user-friendly message for missing tables
725
+ #
726
+ # @param missing [Array<Hash>] Missing table information
727
+ # @return [String] Formatted message
728
+ def build_missing_tables_message(missing)
729
+ lines = ['Rodauth [table_guard] Missing required database tables!']
730
+ lines << ''
731
+
732
+ missing.each do |info|
733
+ lines << " - Table: #{info[:table]} (feature: #{info[:feature]}, method: #{info[:method]})"
734
+ end
735
+
736
+ lines << ''
737
+ lines << build_migration_hints(missing)
738
+
739
+ lines.join("\n")
740
+ end
741
+
742
+ # Build distinctive error message for error-level logging
743
+ #
744
+ # @param missing [Array<Hash>] Missing table information
745
+ # @return [String] Formatted error message
746
+ def build_missing_tables_error(missing)
747
+ table_list = missing.map { |i| i[:table] }.join(', ')
748
+ "CRITICAL: Missing Rodauth tables - #{table_list}"
749
+ end
750
+
751
+ # Build helpful hints for resolving missing tables
752
+ #
753
+ # @param missing [Array<Hash>] Missing table information
754
+ # @return [String] Formatted hints
755
+ def build_migration_hints(missing)
756
+ hints = []
757
+ hints << ''
758
+ hints << '⚠️ DATABASE OPERATIONS WILL FAIL UNTIL TABLES ARE CREATED'
759
+ hints << ''
760
+
761
+ unique_tables = missing.map { |i| i[:table] }.uniq
762
+
763
+ if table_guard_sequel_mode.nil?
764
+ hints << 'Quick fix for development (creates tables automatically):'
765
+ hints << ' table_guard_sequel_mode :create'
766
+ hints << ''
767
+ hints << 'Other options:'
768
+ hints << ' table_guard_sequel_mode :log # Show migration code'
769
+ hints << ' table_guard_sequel_mode :migration # Generate migration file'
770
+ hints << ''
771
+ end
772
+
773
+ hints << 'Required tables:'
774
+ unique_tables.each do |table|
775
+ hints << " - #{table}"
776
+ end
777
+
778
+ hints << ''
779
+ hints << 'To disable checking: table_guard_mode :silent'
780
+ hints << "To skip specific tables: table_guard_skip_tables #{unique_tables.inspect}"
781
+
782
+ hints.join("\n")
783
+ end
784
+
785
+ # Build user-friendly message for missing columns
786
+ #
787
+ # @param missing_cols [Array<Hash>] Missing column information
788
+ # @return [String] Formatted message
789
+ def build_missing_columns_message(missing_cols)
790
+ lines = ['Rodauth [table_guard] Missing required database columns!']
791
+ lines << ''
792
+
793
+ # Group by table
794
+ by_table = missing_cols.group_by { |col| col[:table] }
795
+ by_table.each do |table, cols|
796
+ lines << " Table: #{table}"
797
+ cols.each do |col|
798
+ lines << " - Column: #{col[:column]} (type: #{col[:type]}, feature: #{col[:feature]})"
799
+ end
800
+ end
801
+
802
+ lines << ''
803
+ lines << build_column_migration_hints(missing_cols)
804
+
805
+ lines.join("\n")
806
+ end
807
+
808
+ # Build distinctive error message for columns
809
+ #
810
+ # @param missing_cols [Array<Hash>] Missing column information
811
+ # @return [String] Formatted error message
812
+ def build_missing_columns_error(missing_cols)
813
+ column_list = missing_cols.map { |c| "#{c[:table]}.#{c[:column]}" }.join(', ')
814
+ "CRITICAL: Missing Rodauth columns - #{column_list}"
815
+ end
816
+
817
+ # Build helpful hints for resolving missing columns
818
+ #
819
+ # @param missing_cols [Array<Hash>] Missing column information
820
+ # @return [String] Formatted hints
821
+ def build_column_migration_hints(missing_cols)
822
+ hints = []
823
+ hints << ''
824
+ hints << '⚠️ DATABASE OPERATIONS MAY FAIL UNTIL COLUMNS ARE ADDED'
825
+ hints << ''
826
+
827
+ if table_guard_sequel_mode.nil?
828
+ hints << 'Quick fix for development (adds columns automatically):'
829
+ hints << ' table_guard_sequel_mode :create'
830
+ hints << ''
831
+ hints << 'Other options:'
832
+ hints << ' table_guard_sequel_mode :log # Show migration code'
833
+ hints << ' table_guard_sequel_mode :migration # Generate migration file'
834
+ hints << ''
835
+ end
836
+
837
+ hints << 'Required columns:'
838
+ missing_cols.each do |col|
839
+ hints << " - #{col[:table]}.#{col[:column]} (#{col[:type]}, #{col[:feature]})"
840
+ end
841
+
842
+ hints << ''
843
+ hints << 'To disable checking: table_guard_mode :silent'
844
+
845
+ hints.join("\n")
846
+ end
847
+
848
+ # Get logger instance with fallback chain
849
+ #
850
+ # Checks in order:
851
+ # 1. table_guard_logger (feature-specific logger instance)
852
+ # 2. SemanticLogger[table_guard_logger_name] (if name provided)
853
+ # 3. logger (Rodauth instance method if defined by user)
854
+ # 4. scope.logger (Roda app logger if available)
855
+ # 5. nil (no logger available)
856
+ #
857
+ # For SemanticLogger integration, use table_guard_logger_name instead of
858
+ # table_guard_logger to ensure level configuration is preserved:
859
+ #
860
+ # table_guard_logger_name 'rodauth' # Looks up SemanticLogger['rodauth']
861
+ #
862
+ # Note: SemanticLogger[] creates a new logger instance each time it's called.
863
+ # If you configure logger levels via YAML or code, use table_guard_logger_name
864
+ # so the feature can look up the logger at runtime and get the configured instance.
865
+ def get_logger
866
+ result = table_guard_logger
867
+
868
+ # If no direct logger but name provided, look up SemanticLogger
869
+ result = SemanticLogger[table_guard_logger_name] if !result && table_guard_logger_name && defined?(SemanticLogger)
870
+
871
+ # Fallback chain if still no logger
872
+ result ||= (respond_to?(:logger) ? logger : nil) ||
873
+ (respond_to?(:scope) && scope.respond_to?(:logger) ? scope.logger : nil)
874
+
875
+ # Warn once if logger appears to be SemanticLogger but has no appenders
876
+ if result && result.class.name&.include?('SemanticLogger') &&
877
+ defined?(SemanticLogger) && SemanticLogger.appenders.empty?
878
+ warn '[table_guard] WARNING: SemanticLogger has no appenders configured. ' \
879
+ 'Add: SemanticLogger.add_appender(io: STDOUT, level: :info)'
880
+ end
881
+
882
+ result
883
+ end
884
+
885
+ # Debug logging helper
886
+ def rodauth_debug(msg)
887
+ logger = get_logger
888
+ return unless logger
889
+
890
+ if logger.respond_to?(:debug)
891
+ logger.debug(msg)
892
+ elsif ENV['RODAUTH_DEBUG']
893
+ warn "[DEBUG] #{msg}"
894
+ end
895
+ end
896
+
897
+ # Info logging helper
898
+ def rodauth_info(msg)
899
+ logger = get_logger
900
+
901
+ if logger&.respond_to?(:info)
902
+ logger.info(msg)
903
+ elsif logger&.respond_to?(:<<)
904
+ # Support loggers that only have << method
905
+ logger << "#{msg}\n"
906
+ else
907
+ puts msg
908
+ end
909
+ end
910
+
911
+ # Warn logging helper
912
+ def rodauth_warn(msg)
913
+ logger = get_logger
914
+
915
+ if logger&.respond_to?(:warn)
916
+ logger.warn(msg)
917
+ elsif logger&.respond_to?(:<<)
918
+ logger << "[WARN] #{msg}\n"
919
+ else
920
+ warn msg
921
+ end
922
+ end
923
+
924
+ # Error logging helper
925
+ def rodauth_error(msg)
926
+ logger = get_logger
927
+
928
+ if logger&.respond_to?(:error)
929
+ logger.error(msg)
930
+ elsif logger&.respond_to?(:<<)
931
+ logger << "[ERROR] #{msg}\n"
932
+ else
933
+ warn "[ERROR] #{msg}"
934
+ end
935
+ end
936
+ end
937
+ end