rodauth-tools 0.3.1 → 0.4.1

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.
@@ -136,30 +136,6 @@ module Rodauth
136
136
  mode_value != :silent && mode_value != :skip && !mode_value.nil?
137
137
  end
138
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
139
  # Check required tables and handle based on mode
164
140
  #
165
141
  # This is the main entry point for table validation
@@ -198,9 +174,14 @@ module Rodauth
198
174
  def missing_tables
199
175
  result = []
200
176
 
177
+ # Fetch the set of existing table names ONCE for this pass and reuse it
178
+ # across every table check, instead of running a catalog query per table
179
+ # (N+1). See #table_exists? for why the set is not cached on the instance.
180
+ existing = existing_table_names
181
+
201
182
  table_configuration.each do |method, info|
202
183
  table_name = info[:name]
203
- next if table_exists?(table_name)
184
+ next if table_exists?(table_name, existing)
204
185
 
205
186
  result << {
206
187
  method: method,
@@ -222,33 +203,61 @@ module Rodauth
222
203
 
223
204
  # Check if a table exists in the database
224
205
  #
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).
206
+ # For the common case (an unqualified base table in the default schema) this
207
+ # matches against the database's table/view list rather than probing each
208
+ # table with a SELECT. Sequel's db.table_exists? probe logs the "no such
209
+ # table" exception before catching it internally, which an earlier
210
+ # implementation worked around by clearing and restoring the shared
211
+ # db.loggers array around the call. That mutation of shared connection state
212
+ # was not thread-safe: a concurrent query (e.g. when table_status/
213
+ # column_status are called at runtime) could execute while logging was
214
+ # disabled. Matching against the listed names avoids the failed probe
215
+ # entirely, so no logger suppression — and no shared-state mutation — is
216
+ # needed. Views are included (via db.views when the adapter supports it)
217
+ # because a Rodauth table can legitimately be backed by a view, which
218
+ # db.tables alone omits on most adapters.
219
+ #
220
+ # Schema-qualified names (a Symbol like :auth__accounts, a
221
+ # Sequel::SQL::QualifiedIdentifier, or a Sequel.qualify(...) result) are NOT
222
+ # reflected in db.tables (which returns unqualified names from the current
223
+ # search_path), so they take a separate, schema-aware path: we probe with
224
+ # db.table_exists?. That probe can emit Sequel's error-log noise, but it is
225
+ # confined to this rare qualified path and never fires for the common
226
+ # unqualified case that #116 was about.
227
+ #
228
+ # The optional existing_tables argument lets looping callers
229
+ # (missing_tables, table_status) build the existing-name Set ONCE per
230
+ # introspection pass and reuse it, avoiding N catalog queries. When omitted
231
+ # (the single-name public call) a fresh set is fetched — the set is
232
+ # deliberately NOT cached on the instance so runtime introspection does not
233
+ # go stale if tables are created after boot.
228
234
  #
229
- # @param table_name [String, Symbol] Table name
235
+ # NOTE: On a genuine error we still fail open (assume the table exists) to
236
+ # preserve current behavior; switching this to fail closed is tracked in the
237
+ # table_guard hardening follow-up (issue #116).
238
+ #
239
+ # @param table_name [String, Symbol, Sequel::SQL::QualifiedIdentifier] Table name
240
+ # @param existing_tables [Set<Symbol>, nil] Pre-fetched existing table names
230
241
  # @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)
242
+ def table_exists?(table_name, existing_tables = nil)
243
+ # Symbol/String names may be skipped by configuration. A
244
+ # QualifiedIdentifier does not respond to to_sym, so guard the lookup.
245
+ if table_name.respond_to?(:to_sym) &&
246
+ (table_guard_skip_tables.include?(table_name.to_sym) ||
247
+ table_guard_skip_tables.include?(table_name.to_s))
248
+ return true
249
+ end
250
+
251
+ # Qualified names live outside the current search_path's unqualified
252
+ # listing, so probe them directly (schema-aware) rather than matching the
253
+ # Set. Rare path — the log noise this can produce does not hit boot.
254
+ return db.table_exists?(table_name) if qualified_table_name?(table_name)
255
+
256
+ existing_tables ||= existing_table_names
257
+ existing_tables.include?(table_name.to_sym)
243
258
  rescue StandardError => e
244
259
  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
260
+ true # Assume exists to avoid false positives (see hardening follow-up #116)
252
261
  end
253
262
 
254
263
  # List all required table names (sorted)
@@ -262,12 +271,15 @@ module Rodauth
262
271
  #
263
272
  # @return [Array<Hash>] Status information for each table
264
273
  def table_status
274
+ # Build the existing-name set once and reuse it (see missing_tables).
275
+ existing = existing_table_names
276
+
265
277
  table_configuration.map do |method, info|
266
278
  {
267
279
  method: method,
268
280
  table: info[:name],
269
281
  feature: info[:feature],
270
- exists: table_exists?(info[:name])
282
+ exists: table_exists?(info[:name], existing)
271
283
  }
272
284
  end
273
285
  end
@@ -414,6 +426,89 @@ module Rodauth
414
426
 
415
427
  private
416
428
 
429
+ # Backing method for the +table_configuration+ auth_cached_method.
430
+ #
431
+ # Discovers and returns table configuration. This is called lazily
432
+ # per-instance and the result is cached in @table_configuration.
433
+ #
434
+ # Must stay private: rodauth registers it via auth_private_methods and (as
435
+ # of rodauth 2.45.0) warns when the backing method is not defined privately.
436
+ #
437
+ # @return [Hash<Symbol, Hash>] Table configuration
438
+ def _table_configuration
439
+ config = Rodauth::TableInspector.table_information(self)
440
+ rodauth_debug("[table_guard] Discovered #{config.size} required tables") if ENV['RODAUTH_DEBUG']
441
+ config
442
+ end
443
+
444
+ # Backing method for the +column_requirements+ auth_cached_method.
445
+ #
446
+ # Initializes and returns column requirements hash. This is called lazily
447
+ # per-instance and the result is cached in @column_requirements.
448
+ #
449
+ # Structure: { table_name => { column_name => { type:, null:, feature: } } }
450
+ #
451
+ # @return [Hash<Symbol, Hash<Symbol, Hash>>] Column requirements by table
452
+ def _column_requirements
453
+ {}
454
+ end
455
+
456
+ # Build the set of unqualified table names that currently exist, including
457
+ # views (which db.tables omits on most adapters but which can legitimately
458
+ # back a Rodauth table).
459
+ #
460
+ # Fetched fresh on each call — never memoized on the instance — so runtime
461
+ # introspection reflects tables created after boot. Looping callers pass the
462
+ # result into #table_exists? to fetch it only once per pass (see #116 / N+1).
463
+ #
464
+ # @return [Set<Symbol>] Existing base-table and view names
465
+ def existing_table_names
466
+ names = db.tables.map(&:to_sym)
467
+ names.concat(db.views.map(&:to_sym)) if db.respond_to?(:views)
468
+ # Set.new (not names.to_set): referencing the Set constant triggers its
469
+ # autoload on Ruby >= 3.2 (the gem's floor), whereas Enumerable#to_set is
470
+ # only defined once 'set' is already loaded. Using to_set here would rely
471
+ # on a dependency having required 'set' first and could otherwise raise
472
+ # NoMethodError. This also keeps Lint/RedundantRequireStatement satisfied.
473
+ Set.new(names)
474
+ end
475
+
476
+ # Determine whether a table identifier is schema-qualified.
477
+ #
478
+ # Qualified identifiers are not present in db.tables (which lists unqualified
479
+ # names from the current search_path), so #table_exists? routes them to a
480
+ # schema-aware probe instead of the Set match.
481
+ #
482
+ # Recognizes Sequel's qualified forms: a QualifiedIdentifier (from
483
+ # Sequel.qualify) and the implicit-qualification Symbol form :schema__table.
484
+ # A String with underscores is a literal name, not a qualification.
485
+ #
486
+ # @param table_name [Object] Table identifier
487
+ # @return [Boolean] True if schema-qualified
488
+ def qualified_table_name?(table_name)
489
+ return true if defined?(Sequel::SQL::QualifiedIdentifier) &&
490
+ table_name.is_a?(Sequel::SQL::QualifiedIdentifier)
491
+
492
+ table_name.is_a?(Symbol) && table_name.to_s.include?('__')
493
+ end
494
+
495
+ # Resolve table_guard_mode to its symbol value, or nil when it is a
496
+ # block/Proc handler.
497
+ #
498
+ # A block handler (arity > 0) cannot be evaluated without arguments, and a
499
+ # 0-arity Proc is a custom handler rather than a mode symbol; in both cases
500
+ # there is no symbol to compare against, so we return nil. This lets callers
501
+ # do `%i[raise halt exit].include?(table_guard_mode_symbol)` safely instead
502
+ # of invoking table_guard_mode with the wrong arity.
503
+ #
504
+ # @return [Symbol, nil] the configured mode symbol, or nil for block/Proc handlers
505
+ def table_guard_mode_symbol
506
+ return nil if method(:table_guard_mode).arity > 0
507
+
508
+ value = table_guard_mode
509
+ value.is_a?(Proc) ? nil : value
510
+ end
511
+
417
512
  # Handle column validation based on mode setting
418
513
  #
419
514
  # @param missing_cols [Array<Hash>] Missing column information
@@ -608,22 +703,38 @@ module Rodauth
608
703
  return
609
704
  end
610
705
 
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)
706
+ # Enumerate every table the enabled features' ERB templates create —
707
+ # including "hidden" tables such as account_statuses and
708
+ # account_password_hashes that have no *_table method (RT-09) — and drop
709
+ # them in FK-dependency order via the generator (the same path :sync
710
+ # already uses). The previous code dropped only the discovered *_table
711
+ # names in reversed hash order, so it left the hidden tables in place and
712
+ # the recreate step then failed with "table account_statuses already
713
+ # exists", making :recreate unusable with the default schema.
714
+ features = enabled_template_features
715
+
716
+ rodauth_info("[table_guard] Recreating tables for #{features.size} feature(s) " \
717
+ '(dropping all, creating fresh)...')
718
+
719
+ # Wrap the whole drop+create cycle in one transaction so a failure
720
+ # part-way through cannot leave a partially dropped schema (RT-08).
721
+ # Transactional DDL is a no-op on MySQL (it auto-commits DDL), but it
722
+ # makes PostgreSQL and SQLite atomic, which is exactly where an
723
+ # out-of-order drop would otherwise destroy data and then fail.
724
+ db.transaction do
725
+ generator.execute_drops(db, features: features)
726
+
727
+ # Every required table is now missing, so recreate them all from the
728
+ # templates (base.erb brings back the hidden tables too).
729
+ current_missing = missing_tables
730
+ current_missing_cols = missing_columns
731
+ if current_missing.any? || current_missing_cols.any?
732
+ generator_for_all = Rodauth::SequelGenerator.new(current_missing, self, current_missing_cols)
733
+ generator_for_all.execute_creates(db)
734
+ end
624
735
  end
625
736
 
626
- rodauth_info("[table_guard] Recreated #{all_tables.size} table(s)")
737
+ rodauth_info("[table_guard] Recreated tables for #{features.size} feature(s)")
627
738
 
628
739
  # Re-validate to show success message
629
740
  revalidate_after_creation
@@ -638,17 +749,24 @@ module Rodauth
638
749
  return
639
750
  end
640
751
 
641
- # Get all required tables from configuration
642
- all_tables = table_configuration.map { |_, info| info[:name] }.uniq
752
+ # Drop every table the enabled features' templates create, hidden
753
+ # tables included (RT-09), in FK-dependency order via the generator.
754
+ features = enabled_template_features
643
755
 
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)
756
+ rodauth_info("[table_guard] Dropping tables for #{features.size} feature(s)...")
647
757
 
648
- # Drop Sequel migration tracking tables so migrations re-run from scratch
649
- drop_tables(%i[schema_info schema_migrations])
758
+ # One transaction for the whole drop so it is atomic (RT-08).
759
+ db.transaction do
760
+ generator.execute_drops(db, features: features)
650
761
 
651
- rodauth_info("[table_guard] Dropped #{all_tables.size} table(s) and migration tracking")
762
+ # Drop Sequel migration tracking tables so migrations re-run from
763
+ # scratch. These are independent leaf tables with no ordering
764
+ # constraints, so the simple helper is fine; keeping them in the same
765
+ # transaction makes the whole :drop atomic.
766
+ drop_tables(%i[schema_info schema_migrations])
767
+ end
768
+
769
+ rodauth_info("[table_guard] Dropped tables for #{features.size} feature(s) and migration tracking")
652
770
  rodauth_info('[table_guard] Migrations will run from scratch on next execution')
653
771
 
654
772
  else
@@ -657,7 +775,10 @@ module Rodauth
657
775
  rescue StandardError => e
658
776
  rodauth_error("[table_guard] Sequel generation failed: #{e.class} - #{e.message}")
659
777
  rodauth_error(" Location: #{e.backtrace.first}")
660
- raise if %i[raise halt exit].include?(table_guard_mode)
778
+ # Use the resolved symbol mode: calling table_guard_mode directly would
779
+ # raise ArgumentError here when the user configured a block handler
780
+ # (arity > 0), masking the real error `e` we are trying to surface.
781
+ raise if %i[raise halt exit].include?(table_guard_mode_symbol)
661
782
  end
662
783
 
663
784
  # Check if the database supports CASCADE on DELETE
@@ -667,13 +788,37 @@ module Rodauth
667
788
  %i[postgres mysql].include?(db.database_type)
668
789
  end
669
790
 
670
- # Drop tables with proper CASCADE handling for non-SQLite databases
791
+ # Feature names (matching ERB template basenames) for every discovered
792
+ # required table, de-duplicated.
793
+ #
794
+ # Used by :recreate/:drop to enumerate the full set of tables to drop from
795
+ # the templates — including hidden tables like account_statuses — rather
796
+ # than only the discovered *_table names. A feature whose template is
797
+ # missing simply contributes no tables (TemplateInspector returns [] for
798
+ # it), which is consistent with the create path, which likewise cannot
799
+ # build a table it has no template for.
800
+ #
801
+ # @return [Array<Symbol>] Enabled feature names that own required tables
802
+ def enabled_template_features
803
+ table_configuration.map { |_, info| info[:feature] }.compact.uniq
804
+ end
805
+
806
+ # Drop a set of independent tables (no inter-table foreign keys), with
807
+ # CASCADE where the adapter supports it.
808
+ #
809
+ # This helper does NOT order for foreign-key dependencies. The destructive
810
+ # sequel modes route their FK-ordered drops through
811
+ # SequelGenerator#execute_drops, which enumerates the templates (hidden
812
+ # tables included) and drops child-before-parent. This helper is now used
813
+ # only for the Sequel migration-tracking tables (:schema_info,
814
+ # :schema_migrations) in :drop mode, which have no ordering constraints. It
815
+ # opens no transaction of its own, so a caller can wrap it (together with
816
+ # execute_drops) in a single transaction for atomicity (RT-08).
671
817
  #
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.
818
+ # SQLite doesn't support CASCADE on DROP TABLE, so we detect the database
819
+ # type and avoid it there.
675
820
  #
676
- # @param table_names [Array<String, Symbol>] Tables to drop
821
+ # @param table_names [Array<String, Symbol>] Independent tables to drop
677
822
  def drop_tables(table_names)
678
823
  table_names.each do |table_name|
679
824
  next unless db.table_exists?(table_name)
@@ -0,0 +1,137 @@
1
+ # lib/rodauth/secret_guard.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'securerandom'
6
+
7
+ module Rodauth
8
+ # Shared, secret-kind-parameterized logic behind the +hmac_secret_guard+ and
9
+ # +jwt_secret_guard+ features.
10
+ #
11
+ # The two guard features are nearly identical; the only thing that differs is
12
+ # the "kind" of secret they manage (+:hmac+ or +:jwt+) and the names of the
13
+ # configuration methods that carry that kind as a prefix
14
+ # (+hmac_secret_env_key+ vs +jwt_secret_env_key+, and so on).
15
+ #
16
+ # Keeping this logic in one place — and taking +kind+ as an explicit argument
17
+ # rather than baking it into method names — is what lets both features be
18
+ # enabled at the same time. Each feature's +post_configure+ calls into these
19
+ # helpers with its own +kind+, so both secrets are validated at boot. When the
20
+ # per-feature methods shared a name (the previous design), enabling both
21
+ # guards meant one definition shadowed the other and only a single secret was
22
+ # ever validated.
23
+ #
24
+ # These are plain module functions that take the Rodauth instance explicitly
25
+ # (rather than being mixed in) so there is no method-name surface to collide
26
+ # in the first place.
27
+ module SecretGuard
28
+ module_function
29
+
30
+ # Auto-populate +<kind>_secret+ from its environment variable when it has
31
+ # not been configured explicitly.
32
+ #
33
+ # The variable is read with +ENV.delete+ so the raw secret does not linger
34
+ # in the process environment after boot. A blank (nil/empty/whitespace-only)
35
+ # value is treated as absent and leaves the secret unset for +validate!+ to
36
+ # handle.
37
+ #
38
+ # @param rodauth [Rodauth::Auth] the Rodauth instance being configured
39
+ # @param kind [Symbol] the secret kind (+:hmac+ or +:jwt+)
40
+ # @return [void]
41
+ def load_from_env!(rodauth, kind)
42
+ return unless blank?(rodauth.send(:"#{kind}_secret"))
43
+
44
+ raw = ENV.delete(rodauth.send(:"#{kind}_secret_env_key"))
45
+ value = raw&.strip
46
+ return if value.nil? || value.empty?
47
+
48
+ define_secret(rodauth, kind, value)
49
+ end
50
+
51
+ # Validate that +<kind>_secret+ is usable.
52
+ #
53
+ # In production a missing (nil/empty/whitespace-only) or too-short secret
54
+ # raises +Rodauth::ConfigurationError+ — the guard fails closed. Outside
55
+ # production a missing secret logs a warning and falls back to the
56
+ # (ephemeral, per-process) development fallback.
57
+ #
58
+ # @param rodauth [Rodauth::Auth] the Rodauth instance being configured
59
+ # @param kind [Symbol] the secret kind (+:hmac+ or +:jwt+)
60
+ # @raise [Rodauth::ConfigurationError] when the secret is unusable in production
61
+ # @return [void]
62
+ def validate!(rodauth, kind)
63
+ current = rodauth.send(:"#{kind}_secret")
64
+
65
+ unless blank?(current)
66
+ enforce_minimum_length!(rodauth, kind, current)
67
+ return
68
+ end
69
+
70
+ raise Rodauth::ConfigurationError, rodauth.send(:"#{kind}_secret_missing_error") if rodauth.production?
71
+
72
+ warn(rodauth, rodauth.send(:"#{kind}_secret_dev_warning"))
73
+ define_secret(rodauth, kind, rodauth.send(:"development_#{kind}_secret_fallback"))
74
+ end
75
+
76
+ # Resolve the configured production check into a boolean.
77
+ #
78
+ # A Proc is evaluated in the Rodauth instance context (so it can read config
79
+ # methods); anything else is coerced with +!!+.
80
+ #
81
+ # @param rodauth [Rodauth::Auth] the Rodauth instance being configured
82
+ # @return [Boolean]
83
+ def production?(rodauth)
84
+ check = rodauth.send(:production_env_check)
85
+ check.is_a?(Proc) ? rodauth.instance_exec(&check) : !!check
86
+ end
87
+
88
+ # Enforce +minimum_secret_length+ when it is configured (> 0).
89
+ #
90
+ # Only applied in production so development fallbacks and short test secrets
91
+ # are unaffected. Disabled by default.
92
+ #
93
+ # @return [void]
94
+ def enforce_minimum_length!(rodauth, kind, value)
95
+ minimum = rodauth.send(:minimum_secret_length).to_i
96
+ return if minimum <= 0
97
+ return unless rodauth.production?
98
+ return if value.to_s.strip.length >= minimum
99
+
100
+ # Name the secret generically and cite both configuration avenues: the
101
+ # value may have come from the env var OR from the #{kind}_secret DSL
102
+ # method, so blaming the env key alone would mislead when a short secret
103
+ # was configured directly.
104
+ key = rodauth.send(:"#{kind}_secret_env_key")
105
+ raise Rodauth::ConfigurationError,
106
+ "#{kind.to_s.upcase} secret must be at least #{minimum} characters in production " \
107
+ "(set via #{key} or #{kind}_secret)"
108
+ end
109
+
110
+ # @return [Boolean] true when the value is nil, empty, or whitespace-only
111
+ def blank?(value)
112
+ value.nil? || value.to_s.strip.empty?
113
+ end
114
+
115
+ # Redefine +<kind>_secret+ on the Rodauth subclass to return +value+.
116
+ #
117
+ # This mirrors how Rodauth features memoize resolved config: the auth class
118
+ # is per-configuration and this runs once, single-threaded, at boot.
119
+ #
120
+ # @return [void]
121
+ def define_secret(rodauth, kind, value)
122
+ rodauth.class.send(:define_method, :"#{kind}_secret") { value }
123
+ end
124
+
125
+ # Emit a development warning via the Rodauth logger when present, otherwise
126
+ # to stderr.
127
+ #
128
+ # @return [void]
129
+ def warn(rodauth, message)
130
+ if rodauth.respond_to?(:logger) && rodauth.logger
131
+ rodauth.logger.warn(message)
132
+ else
133
+ Kernel.warn(message)
134
+ end
135
+ end
136
+ end
137
+ end
@@ -263,12 +263,22 @@ module Rodauth
263
263
 
264
264
  # Execute DROP TABLE operations directly against the database
265
265
  #
266
- # Uses TemplateInspector to extract ALL tables from ERB templates.
266
+ # Uses TemplateInspector to extract ALL tables from ERB templates,
267
+ # including "hidden" tables (account_statuses, account_password_hashes)
268
+ # that have no corresponding *_table method.
269
+ #
270
+ # By default the feature set is derived from missing_tables (the :sync
271
+ # path). Callers that need to drop the full schema regardless of what is
272
+ # currently missing — :recreate and :drop, where nothing may be "missing" —
273
+ # pass an explicit +features+ list so the template enumeration still covers
274
+ # every enabled feature (and therefore the hidden tables).
267
275
  #
268
276
  # @param db [Sequel::Database] Database connection
269
- def execute_drops(db)
277
+ # @param features [Array<Symbol>, nil] Explicit feature list to enumerate;
278
+ # when nil, features are inferred from missing_tables
279
+ def execute_drops(db, features: nil)
270
280
  # Extract all tables from ERB templates
271
- all_tables = extract_all_tables_from_templates
281
+ all_tables = extract_all_tables_from_templates(features: features)
272
282
 
273
283
  # Drop in reverse order to handle foreign key dependencies
274
284
  ordered_tables = order_tables_for_drop(all_tables).reverse
@@ -336,9 +346,11 @@ module Rodauth
336
346
  # like account_statuses and account_password_hashes that don't have
337
347
  # corresponding *_table methods in Rodauth.
338
348
  #
349
+ # @param features [Array<Symbol>, nil] Explicit feature list; when nil,
350
+ # features are inferred from missing_tables (the :sync/codegen default)
339
351
  # @return [Array<Symbol>] Array of all table names
340
- def extract_all_tables_from_templates
341
- features = extract_features_from_missing_tables
352
+ def extract_all_tables_from_templates(features: nil)
353
+ features ||= extract_features_from_missing_tables
342
354
  table_prefix = extract_table_prefix
343
355
  db_type = extract_db_type
344
356
 
@@ -97,7 +97,7 @@ module Rodauth
97
97
  next unless feature_module
98
98
 
99
99
  # Check if this feature module defines the table method
100
- return feature_name if feature_module.instance_methods(false).include?(method_name)
100
+ return feature_name if feature_module.method_defined?(method_name, false)
101
101
  end
102
102
 
103
103
  # Fallback: try to infer from method name if not found in any feature