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.
- checksums.yaml +7 -0
- data/.gitignore +93 -0
- data/.gitlint +9 -0
- data/.markdownlint-cli2.jsonc +26 -0
- data/.pre-commit-config.yaml +46 -0
- data/.rubocop.yml +18 -0
- data/.rubocop_todo.yml +243 -0
- data/CHANGELOG.md +81 -0
- data/CLAUDE.md +262 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +111 -0
- data/Gemfile +35 -0
- data/Gemfile.lock +356 -0
- data/LICENSE.txt +21 -0
- data/README.md +339 -0
- data/Rakefile +8 -0
- data/lib/rodauth/features/external_identity.rb +946 -0
- data/lib/rodauth/features/hmac_secret_guard.rb +119 -0
- data/lib/rodauth/features/jwt_secret_guard.rb +120 -0
- data/lib/rodauth/features/table_guard.rb +937 -0
- data/lib/rodauth/sequel_generator.rb +531 -0
- data/lib/rodauth/table_inspector.rb +124 -0
- data/lib/rodauth/template_inspector.rb +134 -0
- data/lib/rodauth/tools/console_helpers.rb +158 -0
- data/lib/rodauth/tools/migration/sequel/account_expiration.erb +9 -0
- data/lib/rodauth/tools/migration/sequel/active_sessions.erb +10 -0
- data/lib/rodauth/tools/migration/sequel/audit_logging.erb +12 -0
- data/lib/rodauth/tools/migration/sequel/base.erb +41 -0
- data/lib/rodauth/tools/migration/sequel/disallow_password_reuse.erb +8 -0
- data/lib/rodauth/tools/migration/sequel/email_auth.erb +17 -0
- data/lib/rodauth/tools/migration/sequel/jwt_refresh.erb +18 -0
- data/lib/rodauth/tools/migration/sequel/lockout.erb +21 -0
- data/lib/rodauth/tools/migration/sequel/otp.erb +9 -0
- data/lib/rodauth/tools/migration/sequel/otp_unlock.erb +8 -0
- data/lib/rodauth/tools/migration/sequel/password_expiration.erb +7 -0
- data/lib/rodauth/tools/migration/sequel/recovery_codes.erb +8 -0
- data/lib/rodauth/tools/migration/sequel/remember.erb +16 -0
- data/lib/rodauth/tools/migration/sequel/reset_password.erb +17 -0
- data/lib/rodauth/tools/migration/sequel/single_session.erb +7 -0
- data/lib/rodauth/tools/migration/sequel/sms_codes.erb +10 -0
- data/lib/rodauth/tools/migration/sequel/verify_account.erb +9 -0
- data/lib/rodauth/tools/migration/sequel/verify_login_change.erb +17 -0
- data/lib/rodauth/tools/migration/sequel/webauthn.erb +15 -0
- data/lib/rodauth/tools/migration.rb +188 -0
- data/lib/rodauth/tools/version.rb +9 -0
- data/lib/rodauth/tools.rb +29 -0
- data/package-lock.json +500 -0
- data/package.json +11 -0
- data/rodauth-tools.gemspec +40 -0
- 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
|