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,531 @@
1
+ # lib/rodauth/sequel_generator.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'table_inspector'
6
+ require_relative 'template_inspector'
7
+
8
+ module Rodauth
9
+ # SequelGenerator generates Sequel migration code for missing Rodauth tables.
10
+ #
11
+ # It uses table structure information from TableInspector to generate
12
+ # appropriate CREATE TABLE statements with proper columns, foreign keys,
13
+ # and indexes.
14
+ #
15
+ # @example Generate migration for missing tables
16
+ # missing = rodauth.missing_tables
17
+ # generator = Rodauth::SequelGenerator.new(missing, rodauth)
18
+ # puts generator.generate_migration
19
+ #
20
+ # @example Generate only CREATE statements
21
+ # puts generator.generate_create_statements
22
+ class SequelGenerator
23
+ attr_reader :missing_tables, :missing_columns, :rodauth_instance, :db
24
+
25
+ # Initialize the Sequel generator
26
+ #
27
+ # @param missing_tables [Array<Hash>] Array of missing table info from table_guard
28
+ # @param rodauth_instance [Rodauth::Auth] Rodauth instance for context
29
+ # @param missing_columns [Array<Hash>] Array of missing column info from table_guard
30
+ def initialize(missing_tables, rodauth_instance, missing_columns = [])
31
+ @missing_tables = missing_tables
32
+ @missing_columns = missing_columns
33
+ @rodauth_instance = rodauth_instance
34
+ @db = rodauth_instance.respond_to?(:db) ? rodauth_instance.db : nil
35
+ end
36
+
37
+ # Generate a complete Sequel migration with up and down blocks
38
+ #
39
+ # @param idempotent [Boolean] Use create_table? for idempotency (default: true)
40
+ # @return [String] Complete Sequel migration code
41
+ def generate_migration(idempotent: true)
42
+ # Generate CREATE TABLE statements for missing tables
43
+ migration_content = if missing_tables.any?
44
+ # Extract unique features from missing tables
45
+ features_needed = extract_features_from_missing_tables
46
+
47
+ # Use Migration class to generate from ERB templates
48
+ migration = create_migration_generator(features_needed)
49
+
50
+ # Generate the migration content
51
+ migration.generate
52
+ else
53
+ ''
54
+ end
55
+
56
+ # Generate ALTER TABLE statements for missing columns
57
+ alter_statements = generate_alter_table_statements
58
+
59
+ # Combine CREATE and ALTER statements
60
+ up_content = if migration_content.strip.empty?
61
+ alter_statements
62
+ elsif alter_statements.strip.empty?
63
+ migration_content
64
+ else
65
+ migration_content.strip + "\n\n" + alter_statements
66
+ end
67
+
68
+ # Generate down statements
69
+ drop_tables = generate_drop_statements
70
+ drop_columns = generate_drop_column_statements
71
+
72
+ down_content = if drop_tables.strip.empty?
73
+ drop_columns
74
+ elsif drop_columns.strip.empty?
75
+ drop_tables
76
+ else
77
+ drop_tables.strip + "\n\n" + drop_columns
78
+ end
79
+
80
+ # Wrap in Sequel.migration block with up/down
81
+ <<~RUBY
82
+ # frozen_string_literal: true
83
+
84
+ Sequel.migration do
85
+ up do
86
+ #{indent(up_content, 4)}
87
+ end
88
+
89
+ down do
90
+ #{indent(down_content, 4)}
91
+ end
92
+ end
93
+ RUBY
94
+ end
95
+
96
+ # Generate only the CREATE TABLE statements
97
+ #
98
+ # @param idempotent [Boolean] Use create_table? for idempotency (default: true)
99
+ # @return [String] Sequel CREATE TABLE code
100
+ def generate_create_statements(idempotent: true)
101
+ # Extract unique features from missing tables
102
+ features_needed = extract_features_from_missing_tables
103
+
104
+ # Use Migration class to generate from ERB templates
105
+ migration = create_migration_generator(features_needed)
106
+
107
+ # Generate the migration content
108
+ migration.generate
109
+ end
110
+
111
+ # Generate DROP TABLE statements
112
+ #
113
+ # Uses TemplateInspector to extract ALL tables from ERB templates,
114
+ # including "hidden" tables that don't have corresponding *_table methods
115
+ # (like account_statuses and account_password_hashes from base.erb).
116
+ #
117
+ # @return [String] Sequel DROP TABLE code
118
+ def generate_drop_statements
119
+ return '' if missing_tables.empty?
120
+
121
+ # Extract all tables from ERB templates (not just discovered methods)
122
+ all_tables = extract_all_tables_from_templates
123
+
124
+ # Drop in reverse order to handle foreign key dependencies
125
+ # (child tables first, then parent tables)
126
+ ordered_tables = order_tables_for_drop(all_tables).reverse
127
+
128
+ statements = ordered_tables.map do |table_name|
129
+ "drop_table?(:#{table_name})"
130
+ end
131
+
132
+ statements.join("\n")
133
+ end
134
+
135
+ # Generate ALTER TABLE statements for missing columns
136
+ #
137
+ # Groups columns by table and generates alter_table blocks
138
+ # for each table with missing columns.
139
+ #
140
+ # @return [String] Sequel ALTER TABLE code
141
+ def generate_alter_table_statements
142
+ return '' if missing_columns.empty?
143
+
144
+ # Group columns by table
145
+ columns_by_table = missing_columns.group_by { |col| col[:table] }
146
+
147
+ statements = columns_by_table.map do |table_name, columns|
148
+ # Generate alter_table block for this table
149
+ alter_block = ["alter_table(:#{table_name}) do"]
150
+
151
+ columns.each do |col|
152
+ # Map Ruby type symbol or class to Sequel column type
153
+ column_type = map_column_type(col[:type])
154
+
155
+ # Build options array
156
+ options = []
157
+ options << "null: #{col[:null]}" unless col[:null].nil?
158
+ options << "default: #{format_default_value(col[:default])}" if col[:default]
159
+ options << 'unique: true' if col[:unique]
160
+ options << "size: #{col[:size]}" if col[:size]
161
+
162
+ options_str = options.empty? ? '' : ", #{options.join(", ")}"
163
+
164
+ alter_block << " add_column :#{col[:column]}, #{column_type}#{options_str}"
165
+
166
+ # Add index if requested
167
+ if col[:index]
168
+ index_opts = col[:unique] ? ', unique: true' : ''
169
+ alter_block << " add_index :#{col[:column]}#{index_opts}"
170
+ end
171
+ end
172
+
173
+ alter_block << 'end'
174
+ alter_block.join("\n")
175
+ end
176
+
177
+ statements.join("\n\n")
178
+ end
179
+
180
+ # Generate DROP COLUMN statements for rolling back column additions
181
+ #
182
+ # @return [String] Sequel DROP COLUMN code
183
+ def generate_drop_column_statements
184
+ return '' if missing_columns.empty?
185
+
186
+ # Group columns by table
187
+ columns_by_table = missing_columns.group_by { |col| col[:table] }
188
+
189
+ statements = columns_by_table.map do |table_name, columns|
190
+ # Generate alter_table block for this table
191
+ alter_block = ["alter_table(:#{table_name}) do"]
192
+
193
+ columns.each do |col|
194
+ alter_block << " drop_column :#{col[:column]}"
195
+ end
196
+
197
+ alter_block << 'end'
198
+ alter_block.join("\n")
199
+ end
200
+
201
+ statements.join("\n\n")
202
+ end
203
+
204
+ # Execute CREATE TABLE operations directly against the database
205
+ #
206
+ # @param db [Sequel::Database] Database connection
207
+ def execute_creates(db)
208
+ # Create tables if there are any missing
209
+ if missing_tables.any?
210
+ # Extract unique features from missing tables
211
+ features_needed = extract_features_from_missing_tables
212
+
213
+ # Use Migration class to execute ERB templates directly
214
+ migration = create_migration_generator(features_needed)
215
+
216
+ begin
217
+ migration.execute_create_tables(db)
218
+ rescue StandardError => e
219
+ raise "Failed to execute table creation: #{e.class} - #{e.message}\n #{e.backtrace.first(5).join("\n ")}"
220
+ end
221
+ end
222
+
223
+ # Execute ALTER TABLE statements for missing columns
224
+ execute_alter_tables(db)
225
+ end
226
+
227
+ # Execute ALTER TABLE operations directly against the database
228
+ #
229
+ # @param db [Sequel::Database] Database connection
230
+ def execute_alter_tables(db)
231
+ return if missing_columns.empty?
232
+
233
+ # Group columns by table
234
+ columns_by_table = missing_columns.group_by { |col| col[:table] }
235
+
236
+ columns_by_table.each do |table_name, columns|
237
+ # Capture self reference before entering alter_table block
238
+ generator = self
239
+
240
+ db.alter_table(table_name.to_sym) do
241
+ columns.each do |col|
242
+ # Map Ruby type symbol or class to Sequel column type
243
+ column_type = generator.send(:map_column_type_for_execution, col[:type])
244
+
245
+ # Build column options
246
+ column_opts = {}
247
+ column_opts[:null] = col[:null] unless col[:null].nil?
248
+ column_opts[:default] = col[:default] if col[:default]
249
+ column_opts[:unique] = true if col[:unique]
250
+ column_opts[:size] = col[:size] if col[:size]
251
+
252
+ add_column col[:column].to_sym, column_type, column_opts
253
+
254
+ # Add index if requested
255
+ if col[:index]
256
+ index_opts = col[:unique] ? { unique: true } : {}
257
+ add_index col[:column].to_sym, index_opts
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
263
+
264
+ # Execute DROP TABLE operations directly against the database
265
+ #
266
+ # Uses TemplateInspector to extract ALL tables from ERB templates.
267
+ #
268
+ # @param db [Sequel::Database] Database connection
269
+ def execute_drops(db)
270
+ # Extract all tables from ERB templates
271
+ all_tables = extract_all_tables_from_templates
272
+
273
+ # Drop in reverse order to handle foreign key dependencies
274
+ ordered_tables = order_tables_for_drop(all_tables).reverse
275
+
276
+ ordered_tables.each do |table_name|
277
+ db.drop_table?(table_name.to_sym)
278
+ end
279
+ end
280
+
281
+ private
282
+
283
+ # Extract unique features from missing tables
284
+ #
285
+ # @return [Array<Symbol>] Array of unique feature names
286
+ def extract_features_from_missing_tables
287
+ features = missing_tables.map { |t| t[:feature] }.compact.uniq
288
+
289
+ # Ensure :base feature is included if accounts table is missing
290
+ if missing_tables.any? { |t| t[:table].to_s.match?(/^accounts?$/) } && !features.include?(:base)
291
+ features.unshift(:base)
292
+ end
293
+
294
+ features
295
+ end
296
+
297
+ # Create a Migration generator instance
298
+ #
299
+ # @param features [Array<Symbol>] Features to generate migrations for
300
+ # @return [Rodauth::Tools::Migration] Migration generator instance
301
+ def create_migration_generator(features)
302
+ # Get table prefix from rodauth instance or use default
303
+ # The prefix should be singular (e.g., "account" not "accounts")
304
+ prefix = if rodauth_instance.respond_to?(:accounts_table)
305
+ table_name = rodauth_instance.accounts_table.to_s
306
+ # Use dry-inflector to singularize the table name
307
+ require 'dry/inflector'
308
+ Dry::Inflector.new.singularize(table_name)
309
+ else
310
+ 'account'
311
+ end
312
+
313
+ Rodauth::Tools::Migration.new(
314
+ features: features,
315
+ prefix: prefix,
316
+ db: db || create_mock_db
317
+ )
318
+ end
319
+
320
+ # Create a mock database for template generation when no real DB is available
321
+ #
322
+ # @return [Rodauth::Tools::Migration::MockSequelDatabase] Mock database
323
+ def create_mock_db
324
+ adapter = if db
325
+ db.database_type
326
+ else
327
+ :postgres # Default to PostgreSQL
328
+ end
329
+
330
+ Rodauth::Tools::Migration::MockSequelDatabase.new(adapter)
331
+ end
332
+
333
+ # Extract all tables from ERB templates for the enabled features
334
+ #
335
+ # This discovers ALL tables that will be created, including "hidden" tables
336
+ # like account_statuses and account_password_hashes that don't have
337
+ # corresponding *_table methods in Rodauth.
338
+ #
339
+ # @return [Array<Symbol>] Array of all table names
340
+ def extract_all_tables_from_templates
341
+ features = extract_features_from_missing_tables
342
+ table_prefix = extract_table_prefix
343
+ db_type = extract_db_type
344
+
345
+ TemplateInspector.all_tables_for_features(
346
+ features,
347
+ table_prefix: table_prefix,
348
+ db_type: db_type
349
+ )
350
+ end
351
+
352
+ # Extract table prefix from rodauth instance
353
+ #
354
+ # @return [String] Table prefix (singular form, e.g., "account")
355
+ def extract_table_prefix
356
+ if rodauth_instance.respond_to?(:accounts_table)
357
+ table_name = rodauth_instance.accounts_table.to_s
358
+ require 'dry/inflector'
359
+ Dry::Inflector.new.singularize(table_name)
360
+ else
361
+ 'account'
362
+ end
363
+ end
364
+
365
+ # Extract database type for template evaluation
366
+ #
367
+ # @return [Symbol] Database type (:postgres, :mysql, :sqlite)
368
+ def extract_db_type
369
+ if db
370
+ db.database_type
371
+ else
372
+ :postgres # Default
373
+ end
374
+ end
375
+
376
+ # Order tables for dropping (reverse dependency order)
377
+ #
378
+ # Rules:
379
+ # - Feature tables first (have foreign keys to parent tables)
380
+ # - Then account_password_hashes (foreign key to accounts)
381
+ # - Then accounts (foreign key to account_statuses)
382
+ # - Finally account_statuses (no dependencies)
383
+ #
384
+ # @param tables [Array<Symbol>] Table names
385
+ # @return [Array<Symbol>] Ordered table names
386
+ def order_tables_for_drop(tables)
387
+ # Categorize tables by dependency level
388
+ statuses_tables = []
389
+ accounts_tables = []
390
+ password_hash_tables = []
391
+ feature_tables = []
392
+
393
+ tables.each do |table_name|
394
+ table_str = table_name.to_s
395
+ if table_str.end_with?('_statuses')
396
+ statuses_tables << table_name
397
+ elsif table_str.match?(/^accounts?$/)
398
+ accounts_tables << table_name
399
+ elsif table_str.match?(/_password_hashes?$/)
400
+ password_hash_tables << table_name
401
+ else
402
+ feature_tables << table_name
403
+ end
404
+ end
405
+
406
+ # Return in creation order (statuses, then accounts, then others)
407
+ # Caller will reverse this for dropping
408
+ statuses_tables + accounts_tables + password_hash_tables + feature_tables
409
+ end
410
+
411
+ # Order tables by dependency (accounts table first, then feature tables)
412
+ def order_tables_by_dependency
413
+ primary_tables = []
414
+ feature_tables = []
415
+
416
+ missing_tables.each do |table_info|
417
+ table_name = table_info[:table].to_s
418
+ method_name = table_info[:method]
419
+
420
+ structure = TableInspector.infer_table_structure(method_name, table_name)
421
+
422
+ if structure[:type] == :primary || table_name.match?(/^accounts?$/)
423
+ primary_tables << table_info
424
+ else
425
+ feature_tables << table_info
426
+ end
427
+ end
428
+
429
+ primary_tables + feature_tables
430
+ end
431
+
432
+ # Get the accounts table name from Rodauth instance
433
+ def accounts_table_name
434
+ if rodauth_instance.respond_to?(:accounts_table)
435
+ rodauth_instance.accounts_table
436
+ else
437
+ :accounts
438
+ end
439
+ end
440
+
441
+ # Check if database is PostgreSQL
442
+ def postgres?
443
+ return false unless db
444
+
445
+ db.database_type == :postgres
446
+ rescue StandardError
447
+ false
448
+ end
449
+
450
+ # Check if database supports partial indexes
451
+ def supports_partial_indexes?
452
+ return false unless db
453
+
454
+ %i[postgres sqlite].include?(db.database_type)
455
+ rescue StandardError
456
+ false
457
+ end
458
+
459
+ # Indent each line of text
460
+ #
461
+ # @param text [String] Text to indent
462
+ # @param spaces [Integer] Number of spaces
463
+ # @return [String] Indented text
464
+ def indent(text, spaces)
465
+ text.lines.map { |line| line.strip.empty? ? line : (' ' * spaces) + line }.join
466
+ end
467
+
468
+ # Format default value for Sequel migration code
469
+ #
470
+ # @param value [Object] Default value to format
471
+ # @return [String] Formatted value for migration code
472
+ def format_default_value(value)
473
+ case value
474
+ when Symbol then value.inspect
475
+ when String then value.inspect
476
+ when Numeric, TrueClass, FalseClass then value.to_s
477
+ when nil then 'nil'
478
+ when Proc then "-> { #{value.call} }" # Evaluate proc for migration
479
+ else value.inspect
480
+ end
481
+ end
482
+
483
+ # Map column type symbol or class to Sequel migration code representation
484
+ #
485
+ # @param type [Symbol, Class] Column type (:String, String, :Integer, Integer, etc.)
486
+ # @return [String] Sequel column type code
487
+ def map_column_type(type)
488
+ # Handle both Symbol and Class forms
489
+ # Note: Can't use case/when with Class constants directly, use equality checks
490
+ if [String, :String, :Text].include?(type)
491
+ 'String'
492
+ elsif [Integer, :Integer].include?(type)
493
+ 'Integer'
494
+ elsif %i[Bignum BigDecimal].include?(type)
495
+ 'Bignum'
496
+ elsif [TrueClass, FalseClass, :Boolean].include?(type)
497
+ 'TrueClass'
498
+ elsif [Date, :Date].include?(type)
499
+ 'Date'
500
+ elsif [DateTime, Time, :DateTime, :Time].include?(type)
501
+ 'DateTime'
502
+ else
503
+ 'String' # Default to String for unknown types
504
+ end
505
+ end
506
+
507
+ # Map column type symbol or class to Sequel execution type
508
+ #
509
+ # @param type [Symbol, Class] Column type (:String, String, :Integer, Integer, etc.)
510
+ # @return [Symbol, Class] Sequel column type for execution
511
+ def map_column_type_for_execution(type)
512
+ # Handle both Symbol and Class forms
513
+ # Note: Can't use case/when with Class constants directly, use equality checks
514
+ if [String, :String, :Text].include?(type)
515
+ String
516
+ elsif [Integer, :Integer].include?(type)
517
+ Integer
518
+ elsif %i[Bignum BigDecimal].include?(type)
519
+ :Bignum
520
+ elsif [TrueClass, FalseClass, :Boolean].include?(type)
521
+ TrueClass
522
+ elsif [Date, :Date].include?(type)
523
+ Date
524
+ elsif [DateTime, Time, :DateTime, :Time].include?(type)
525
+ DateTime
526
+ else
527
+ String # Default to String for unknown types
528
+ end
529
+ end
530
+ end
531
+ end
@@ -0,0 +1,124 @@
1
+ # lib/rodauth/table_inspector.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ module Rodauth
6
+ # TableInspector dynamically discovers database tables required by enabled Rodauth features.
7
+ #
8
+ # Unlike the old static CONFIGURATION approach, this module inspects a Rodauth instance
9
+ # at runtime to discover which tables are needed based on the features that have been enabled.
10
+ #
11
+ # @example Discover tables from a Rodauth instance
12
+ # tables = Rodauth::TableInspector.discover_tables(rodauth_instance)
13
+ # # => { accounts_table: "accounts", otp_keys_table: "account_otp_keys", ... }
14
+ #
15
+ # @example Get detailed table information
16
+ # info = Rodauth::TableInspector.table_information(rodauth_instance)
17
+ # # => {
18
+ # # accounts_table: {
19
+ # # name: "accounts",
20
+ # # feature: :base,
21
+ # # columns: [:id, :email, :password_hash, ...]
22
+ # # },
23
+ # # ...
24
+ # # }
25
+ module TableInspector
26
+ # Discover all table configuration methods and their values from a Rodauth instance
27
+ #
28
+ # @param rodauth_instance [Rodauth::Auth] A Rodauth auth instance
29
+ # @return [Hash<Symbol, String>] Map of method names to table names
30
+ def self.discover_tables(rodauth_instance)
31
+ table_methods = rodauth_instance.methods.select { |m| m.to_s.end_with?('_table') }
32
+
33
+ tables = {}
34
+ table_methods.each do |method|
35
+ table_name = rodauth_instance.send(method)
36
+ tables[method] = table_name if table_name.is_a?(String) || table_name.is_a?(Symbol)
37
+ rescue StandardError => e
38
+ # Some table methods might fail if called without proper context
39
+ warn "TableInspector: Unable to call #{method}: #{e.message}" if ENV['RODAUTH_DEBUG']
40
+ end
41
+
42
+ tables
43
+ end
44
+
45
+ # Build detailed table information including inferred structure
46
+ #
47
+ # @param rodauth_instance [Rodauth::Auth] A Rodauth auth instance
48
+ # @return [Hash<Symbol, Hash>] Detailed information about each table
49
+ def self.table_information(rodauth_instance)
50
+ discovered = discover_tables(rodauth_instance)
51
+
52
+ discovered.transform_values do |table_name|
53
+ method_name = discovered.key(table_name)
54
+ feature = infer_feature_from_method(method_name, rodauth_instance)
55
+ template_name = "#{feature}.erb"
56
+
57
+ # Check if ERB template exists for this feature
58
+ # If not, mark with warning flag for downstream handling
59
+ template_exists = Rodauth::Tools::Migration.template_exists?(feature)
60
+
61
+ info = {
62
+ name: table_name,
63
+ feature: feature,
64
+ template: template_name,
65
+ structure: infer_table_structure(method_name, table_name)
66
+ }
67
+
68
+ # Add warning metadata if template is missing
69
+ unless template_exists
70
+ info[:template_missing] = true
71
+ info[:warning] = "No ERB template found for feature: #{feature} (#{template_name})"
72
+ end
73
+
74
+ info
75
+ end
76
+ end
77
+
78
+ # Infer which feature owns a table based on the method name
79
+ #
80
+ # Uses dynamic feature discovery by checking which enabled Rodauth feature
81
+ # defines the table method. This eliminates the need for hardcoded mappings.
82
+ #
83
+ # @param method_name [Symbol] The table method name (e.g., :otp_keys_table)
84
+ # @param rodauth_instance [Rodauth::Auth] A Rodauth auth instance
85
+ # @return [Symbol, nil] The feature name (e.g., :otp) or nil if not found
86
+ def self.infer_feature_from_method(method_name, rodauth_instance)
87
+ # Special case for base feature tables
88
+ # The accounts_table and password_hash_table are fundamental and always present
89
+ return :base if %w[accounts_table password_hash_table account_password_hash_table].include?(method_name.to_s)
90
+
91
+ # Get the Rodauth class (configuration) from the instance
92
+ rodauth_class = rodauth_instance.class
93
+
94
+ # Search through enabled features to find which one defines this method
95
+ rodauth_class.features.each do |feature_name|
96
+ feature_module = Rodauth::FEATURES[feature_name]
97
+ next unless feature_module
98
+
99
+ # Check if this feature module defines the table method
100
+ return feature_name if feature_module.instance_methods(false).include?(method_name)
101
+ end
102
+
103
+ # Fallback: try to infer from method name if not found in any feature
104
+ # This handles edge cases where features might not be fully loaded
105
+ method_str = method_name.to_s.sub(/_table$/, '')
106
+ method_str.to_sym
107
+ end
108
+
109
+ # Infer the structure of a table based on patterns
110
+ #
111
+ # This is a simplified placeholder that returns basic metadata.
112
+ # The actual table structure is defined in ERB templates, not hardcoded here.
113
+ #
114
+ # @param method_name [Symbol] The table method name
115
+ # @param table_name [String, Symbol] The actual table name
116
+ # @return [Hash] Table structure metadata (minimal, for compatibility)
117
+ def self.infer_table_structure(_method_name, _table_name)
118
+ # Return minimal structure - the actual schema is in ERB templates
119
+ {
120
+ type: :feature
121
+ }
122
+ end
123
+ end
124
+ end