better_auth 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +4 -4
  4. data/lib/better_auth/adapters/memory.rb +131 -17
  5. data/lib/better_auth/adapters/sql.rb +139 -57
  6. data/lib/better_auth/configuration.rb +7 -1
  7. data/lib/better_auth/cookies.rb +11 -3
  8. data/lib/better_auth/doctor.rb +97 -0
  9. data/lib/better_auth/endpoint.rb +88 -5
  10. data/lib/better_auth/http_client.rb +46 -0
  11. data/lib/better_auth/migration_plan.rb +15 -0
  12. data/lib/better_auth/oauth2.rb +1 -1
  13. data/lib/better_auth/plugins/admin.rb +6 -1
  14. data/lib/better_auth/plugins/anonymous.rb +2 -0
  15. data/lib/better_auth/plugins/captcha.rb +1 -1
  16. data/lib/better_auth/plugins/device_authorization.rb +34 -0
  17. data/lib/better_auth/plugins/dub.rb +8 -0
  18. data/lib/better_auth/plugins/generic_oauth.rb +34 -7
  19. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  20. data/lib/better_auth/plugins/jwt.rb +10 -3
  21. data/lib/better_auth/plugins/mcp/schema.rb +13 -13
  22. data/lib/better_auth/plugins/mcp.rb +41 -0
  23. data/lib/better_auth/plugins/oauth_protocol.rb +98 -21
  24. data/lib/better_auth/plugins/oidc_provider.rb +62 -3
  25. data/lib/better_auth/plugins/one_tap.rb +17 -5
  26. data/lib/better_auth/plugins/open_api.rb +42 -2
  27. data/lib/better_auth/plugins/organization.rb +122 -11
  28. data/lib/better_auth/plugins/phone_number.rb +1 -1
  29. data/lib/better_auth/plugins/two_factor.rb +21 -0
  30. data/lib/better_auth/rate_limiter.rb +7 -2
  31. data/lib/better_auth/routes/account.rb +4 -0
  32. data/lib/better_auth/routes/email_verification.rb +5 -1
  33. data/lib/better_auth/routes/password.rb +1 -0
  34. data/lib/better_auth/routes/social.rb +29 -1
  35. data/lib/better_auth/routes/user.rb +6 -2
  36. data/lib/better_auth/schema/sql.rb +104 -15
  37. data/lib/better_auth/schema.rb +35 -2
  38. data/lib/better_auth/session.rb +2 -1
  39. data/lib/better_auth/social_providers/base.rb +4 -9
  40. data/lib/better_auth/social_providers/facebook.rb +1 -1
  41. data/lib/better_auth/social_providers/github.rb +2 -0
  42. data/lib/better_auth/social_providers/line.rb +1 -1
  43. data/lib/better_auth/social_providers/paypal.rb +1 -1
  44. data/lib/better_auth/sql_migration.rb +566 -0
  45. data/lib/better_auth/version.rb +1 -1
  46. data/lib/better_auth.rb +3 -0
  47. metadata +10 -6
@@ -7,6 +7,7 @@ require "net/http"
7
7
  require "openssl"
8
8
  require "time"
9
9
  require "uri"
10
+ require_relative "../http_client"
10
11
 
11
12
  module BetterAuth
12
13
  module SocialProviders
@@ -67,11 +68,7 @@ module BetterAuth
67
68
  client_secret: client_secret
68
69
  )
69
70
  end,
70
- verify_id_token: opts[:verify_id_token] || lambda do |token, _nonce = nil|
71
- return false if opts[:disable_id_token_sign_in]
72
-
73
- !decode_jwt_payload(token).empty?
74
- end,
71
+ verify_id_token: opts[:verify_id_token],
75
72
  get_user_info: lambda do |tokens|
76
73
  custom = opts[:get_user_info]
77
74
  profile = if custom
@@ -277,7 +274,7 @@ module BetterAuth
277
274
  return nil if max_age && payload["iat"] && payload["iat"].to_i < Time.now.to_i - max_age.to_i
278
275
 
279
276
  payload
280
- rescue JWT::DecodeError, JSON::ParserError, ArgumentError, OpenSSL::PKey::PKeyError
277
+ rescue JWT::DecodeError, JSON::ParserError, ArgumentError, OpenSSL::PKey::PKeyError, Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError
281
278
  nil
282
279
  end
283
280
 
@@ -312,9 +309,7 @@ module BetterAuth
312
309
  end
313
310
 
314
311
  def request_json(uri, request)
315
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
316
- http.request(request)
317
- end
312
+ HTTPClient.request(uri, request)
318
313
  end
319
314
 
320
315
  def padded_base64(value)
@@ -28,7 +28,7 @@ module BetterAuth
28
28
  },
29
29
  **options
30
30
  )
31
- provider[:verify_id_token] = provider[:options][:verify_id_token] || ->(token, _nonce = nil) { provider[:options][:disable_id_token_sign_in] ? false : !token.to_s.empty? }
31
+ provider.delete(:verify_id_token) unless provider[:verify_id_token]
32
32
  provider
33
33
  end
34
34
  end
@@ -43,6 +43,8 @@ module BetterAuth
43
43
  "User-Agent" => "better-auth"
44
44
  }
45
45
  profile = Base.get_json(user_info_endpoint, headers)
46
+ next nil unless profile
47
+
46
48
  emails = Base.get_json(emails_endpoint, headers)
47
49
  primary = Array(emails).find { |email| email["email"] == profile["email"] } ||
48
50
  Array(emails).find { |email| email["primary"] } ||
@@ -26,7 +26,7 @@ module BetterAuth
26
26
  },
27
27
  **options
28
28
  )
29
- provider[:verify_id_token] = provider[:options][:verify_id_token] || ->(token, _nonce = nil) { provider[:options][:disable_id_token_sign_in] ? false : !Base.decode_jwt_payload(token).empty? }
29
+ provider.delete(:verify_id_token) unless provider[:verify_id_token]
30
30
  provider
31
31
  end
32
32
  end
@@ -29,7 +29,7 @@ module BetterAuth
29
29
  },
30
30
  **options
31
31
  )
32
- provider[:verify_id_token] = provider[:options][:verify_id_token] || ->(token, _nonce = nil) { provider[:options][:disable_id_token_sign_in] ? false : !!Base.decode_jwt_payload(token)["sub"] }
32
+ provider.delete(:verify_id_token) unless provider[:verify_id_token]
33
33
  provider
34
34
  end
35
35
  end
@@ -0,0 +1,566 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "better_auth/migration_plan"
5
+
6
+ module BetterAuth
7
+ module SQLMigration
8
+ DEFAULT_MIGRATIONS_PATH = "db/better_auth/migrate"
9
+ MISSING_MIGRATIONS_TABLE_MESSAGES = [
10
+ /no such table/i,
11
+ /relation .* does not exist/i,
12
+ /table .* doesn't exist/i,
13
+ /undefined table/i,
14
+ /invalid object name/i
15
+ ].freeze
16
+
17
+ class UnsupportedAdapterError < StandardError; end
18
+
19
+ module_function
20
+
21
+ def render(options, dialect:, generator:)
22
+ dialect = normalize_dialect(dialect)
23
+ config = configuration_for(options)
24
+ statements = BetterAuth::Schema::SQL.create_statements(config, dialect: dialect)
25
+ [
26
+ "-- Generated by #{generator}",
27
+ "-- Dialect: #{dialect}",
28
+ "",
29
+ statements.join("\n\n"),
30
+ ""
31
+ ].join("\n")
32
+ end
33
+
34
+ def plan(options, connection:, dialect:)
35
+ dialect = normalize_dialect(dialect)
36
+ plan_from_existing(options, existing: current_schema(connection, dialect), dialect: dialect)
37
+ end
38
+
39
+ def plan_from_existing(options, existing:, dialect:)
40
+ dialect = normalize_dialect(dialect)
41
+ config = configuration_for(options)
42
+ desired = BetterAuth::Schema.auth_tables(config)
43
+ to_create = []
44
+ to_add = []
45
+ to_index = []
46
+ warnings = []
47
+
48
+ desired.each do |logical_name, table|
49
+ table_name = table.fetch(:model_name)
50
+ existing_table = existing[table_name]
51
+
52
+ unless existing_table
53
+ to_create << BetterAuth::MigrationPlan::TableChange.new(
54
+ logical_name: logical_name,
55
+ table_name: table_name,
56
+ table: table,
57
+ order: table[:order] || Float::INFINITY
58
+ )
59
+ table.fetch(:fields).each do |field, attributes|
60
+ next unless indexable_field?(attributes, dialect)
61
+
62
+ column = attributes[:field_name] || physical_name(field)
63
+ to_index << index_change(table_name, column, attributes, dialect: dialect, unique: !!attributes[:unique])
64
+ end
65
+ next
66
+ end
67
+
68
+ missing_fields = {}
69
+ table.fetch(:fields).each do |field, attributes|
70
+ column = attributes[:field_name] || physical_name(field)
71
+ existing_type = existing_table.fetch(:columns)[column]
72
+ if existing_type.nil?
73
+ missing_fields[field] = attributes
74
+ elsif !matching_type?(existing_type, attributes[:type], dialect)
75
+ warnings << "Type mismatch for #{table_name}.#{column}: expected #{attributes[:type]} but found #{existing_type.to_s.downcase}"
76
+ end
77
+
78
+ next unless attributes[:index] || attributes[:unique]
79
+ next if index_present?(existing_table, column, unique: !!attributes[:unique])
80
+
81
+ to_index << index_change(table_name, column, attributes, dialect: dialect, unique: !!attributes[:unique])
82
+ end
83
+
84
+ next if missing_fields.empty?
85
+
86
+ to_add << BetterAuth::MigrationPlan::FieldChange.new(
87
+ logical_name: logical_name,
88
+ table_name: table_name,
89
+ fields: missing_fields,
90
+ table: table,
91
+ order: table[:order] || Float::INFINITY
92
+ )
93
+ end
94
+
95
+ BetterAuth::MigrationPlan::Plan.new(
96
+ to_create: to_create.sort_by(&:order),
97
+ to_add: to_add.sort_by(&:order),
98
+ to_index: to_index,
99
+ warnings: warnings,
100
+ dialect: dialect,
101
+ tables: desired
102
+ )
103
+ end
104
+
105
+ def render_pending(options, connection:, dialect:, generator:)
106
+ dialect = normalize_dialect(dialect)
107
+ migration_plan = plan(options, connection: connection, dialect: dialect)
108
+ statements = BetterAuth::Schema::SQL.pending_statements(migration_plan)
109
+ return "" if statements.empty?
110
+
111
+ [
112
+ "-- Generated by #{generator}",
113
+ "-- Dialect: #{dialect}",
114
+ "",
115
+ statements.join("\n\n"),
116
+ ""
117
+ ].join("\n")
118
+ end
119
+
120
+ def generate(options, dialect:, generator:, migrations_path: DEFAULT_MIGRATIONS_PATH, timestamp: Time.now.utc.strftime("%Y%m%d%H%M%S"), connection: nil)
121
+ dialect = normalize_dialect(dialect)
122
+ FileUtils.mkdir_p(migrations_path)
123
+ path = File.join(migrations_path, "#{timestamp}_create_better_auth_tables.sql")
124
+ return path if File.exist?(path)
125
+
126
+ sql = if connection
127
+ render_pending(options, connection: connection, dialect: dialect, generator: generator)
128
+ else
129
+ render(options, dialect: dialect, generator: generator)
130
+ end
131
+ return nil if sql.empty?
132
+
133
+ File.write(path, sql)
134
+ path
135
+ end
136
+
137
+ def migrate(auth_or_options, migrations_path: DEFAULT_MIGRATIONS_PATH)
138
+ auth = auth_for(auth_or_options)
139
+ adapter = auth.context.adapter
140
+ unless adapter.respond_to?(:dialect) && adapter.respond_to?(:connection)
141
+ raise UnsupportedAdapterError, "Better Auth SQL migrations require core SQL adapters with connection and dialect support"
142
+ end
143
+
144
+ connection = adapter.connection
145
+ dialect = normalize_dialect(adapter.dialect)
146
+ files = Dir[File.join(migrations_path, "*.sql")].sort
147
+ ensure_schema_migrations!(connection, dialect)
148
+ applied = applied_migrations(connection, dialect)
149
+
150
+ files.reject { |file| applied.include?(File.basename(file)) }.each do |file|
151
+ execute_sql(connection, File.read(file))
152
+ record_migration(connection, dialect, File.basename(file))
153
+ end
154
+ end
155
+
156
+ def migrate_pending(auth_or_options)
157
+ auth = auth_for(auth_or_options)
158
+ adapter = auth.context.adapter
159
+ unless adapter.respond_to?(:dialect) && adapter.respond_to?(:connection)
160
+ raise UnsupportedAdapterError, "Better Auth SQL migrations require core SQL adapters with connection and dialect support"
161
+ end
162
+
163
+ connection = adapter.connection
164
+ dialect = normalize_dialect(adapter.dialect)
165
+ sql = render_pending(auth.options, connection: connection, dialect: dialect, generator: "better_auth")
166
+ return false if sql.empty?
167
+
168
+ if adapter.respond_to?(:transaction)
169
+ adapter.transaction { execute_sql(connection, sql) }
170
+ else
171
+ execute_sql(connection, sql)
172
+ end
173
+ true
174
+ end
175
+
176
+ def configuration_for(options)
177
+ return options.options if options.is_a?(BetterAuth::Auth)
178
+ return options if options.is_a?(BetterAuth::Configuration)
179
+
180
+ BetterAuth::Configuration.new(options)
181
+ end
182
+
183
+ def auth_for(value)
184
+ return value if value.is_a?(BetterAuth::Auth)
185
+
186
+ BetterAuth.auth(value)
187
+ end
188
+
189
+ def ensure_schema_migrations!(connection, dialect)
190
+ sql = case dialect
191
+ when :postgres, :sqlite
192
+ %(CREATE TABLE IF NOT EXISTS #{quote("better_auth_schema_migrations", dialect)} (#{quote("version", dialect)} text PRIMARY KEY);)
193
+ when :mysql
194
+ %(CREATE TABLE IF NOT EXISTS #{quote("better_auth_schema_migrations", dialect)} (#{quote("version", dialect)} varchar(191) PRIMARY KEY) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;)
195
+ when :mssql
196
+ %(IF OBJECT_ID(N'#{quote("better_auth_schema_migrations", dialect)}', N'U') IS NULL CREATE TABLE #{quote("better_auth_schema_migrations", dialect)} (#{quote("version", dialect)} varchar(255) PRIMARY KEY);)
197
+ else
198
+ raise UnsupportedAdapterError, "Unsupported SQL dialect for Better Auth migrations: #{dialect}"
199
+ end
200
+ execute_sql(connection, sql)
201
+ end
202
+
203
+ def applied_migrations(connection, dialect)
204
+ rows = execute_sql(connection, "SELECT #{quote("version", dialect)} FROM #{quote("better_auth_schema_migrations", dialect)};")
205
+ Array(rows).map { |row| row["version"] || row[:version] }
206
+ rescue UnsupportedAdapterError
207
+ raise
208
+ rescue => error
209
+ raise error unless missing_schema_migrations_table?(error)
210
+
211
+ []
212
+ end
213
+
214
+ def record_migration(connection, dialect, version)
215
+ sql = "INSERT INTO #{quote("better_auth_schema_migrations", dialect)} (#{quote("version", dialect)}) VALUES (#{literal(version)});"
216
+ execute_sql(connection, sql)
217
+ end
218
+
219
+ def execute_sql(connection, sql)
220
+ statements(sql).each_with_object([]) do |statement, results|
221
+ result =
222
+ if connection.respond_to?(:exec)
223
+ connection.exec(statement)
224
+ elsif connection.respond_to?(:execute)
225
+ connection.execute(statement)
226
+ elsif connection.respond_to?(:query)
227
+ connection.query(statement)
228
+ else
229
+ raise UnsupportedAdapterError, "SQL connection does not support exec, execute, or query"
230
+ end
231
+ results.concat(result.to_a) if result.respond_to?(:to_a)
232
+ end
233
+ end
234
+
235
+ def statements(sql)
236
+ normalized = sql.to_s.gsub("\r\n", "\n").strip
237
+ return [] if normalized.empty?
238
+
239
+ split_sql_statements(normalized)
240
+ end
241
+
242
+ def split_sql_statements(sql)
243
+ output = []
244
+ buffer = +""
245
+ index = 0
246
+ quote = nil
247
+ line_comment = false
248
+ block_comment = false
249
+ dollar_tag = nil
250
+
251
+ while index < sql.length
252
+ state = {
253
+ quote: quote,
254
+ line_comment: line_comment,
255
+ block_comment: block_comment,
256
+ dollar_tag: dollar_tag
257
+ }
258
+ index, quote, line_comment, block_comment, dollar_tag = scan_sql_character(sql, buffer, index, state, output)
259
+ end
260
+
261
+ tail = buffer.strip
262
+ output << tail unless tail.empty?
263
+ output
264
+ end
265
+
266
+ def scan_sql_character(sql, buffer, index, state, output)
267
+ char = sql[index]
268
+ next_char = sql[index + 1]
269
+ quote = state[:quote]
270
+ line_comment = state[:line_comment]
271
+ block_comment = state[:block_comment]
272
+ dollar_tag = state[:dollar_tag]
273
+
274
+ return scan_line_comment(buffer, index, char) + [quote, false, block_comment, dollar_tag] if line_comment && char == "\n"
275
+ return scan_line_comment(buffer, index, char) + [quote, true, block_comment, dollar_tag] if line_comment
276
+ return scan_block_comment(buffer, index, char, next_char, quote, line_comment, dollar_tag) if block_comment
277
+ return scan_dollar_quote(sql, buffer, index, char, quote, line_comment, block_comment, dollar_tag) if dollar_tag
278
+ return scan_quoted_string(sql, buffer, index, char, quote, line_comment, block_comment, dollar_tag) if quote
279
+ return [index + 2, quote, true, block_comment, dollar_tag].tap { buffer << char << next_char } if char == "-" && next_char == "-"
280
+ return [index + 2, quote, line_comment, true, dollar_tag].tap { buffer << char << next_char } if char == "/" && next_char == "*"
281
+
282
+ tag = dollar_quote_tag_at(sql, index)
283
+ return [index + tag.length, quote, line_comment, block_comment, tag].tap { buffer << tag } if tag
284
+ return [index + 1, char, line_comment, block_comment, dollar_tag].tap { buffer << char } if char == "'" || char == "\""
285
+
286
+ if char == ";"
287
+ statement = buffer.strip
288
+ output << statement unless statement.empty?
289
+ buffer.clear
290
+ return [index + 1, quote, line_comment, block_comment, dollar_tag]
291
+ end
292
+
293
+ buffer << char
294
+ [index + 1, quote, line_comment, block_comment, dollar_tag]
295
+ end
296
+
297
+ def scan_line_comment(buffer, index, char)
298
+ buffer << char
299
+ [index + 1]
300
+ end
301
+
302
+ def scan_block_comment(buffer, index, char, next_char, quote, line_comment, dollar_tag)
303
+ buffer << char
304
+ if char == "*" && next_char == "/"
305
+ buffer << next_char
306
+ [index + 2, quote, line_comment, false, dollar_tag]
307
+ else
308
+ [index + 1, quote, line_comment, true, dollar_tag]
309
+ end
310
+ end
311
+
312
+ def scan_dollar_quote(sql, buffer, index, char, quote, line_comment, block_comment, dollar_tag)
313
+ if sql[index, dollar_tag.length] == dollar_tag
314
+ buffer << dollar_tag
315
+ [index + dollar_tag.length, quote, line_comment, block_comment, nil]
316
+ else
317
+ buffer << char
318
+ [index + 1, quote, line_comment, block_comment, dollar_tag]
319
+ end
320
+ end
321
+
322
+ def scan_quoted_string(sql, buffer, index, char, quote, line_comment, block_comment, dollar_tag)
323
+ buffer << char
324
+ if char == quote && sql[index + 1] == quote
325
+ buffer << sql[index + 1]
326
+ [index + 2, quote, line_comment, block_comment, dollar_tag]
327
+ elsif char == quote
328
+ [index + 1, nil, line_comment, block_comment, dollar_tag]
329
+ else
330
+ [index + 1, quote, line_comment, block_comment, dollar_tag]
331
+ end
332
+ end
333
+
334
+ def dollar_quote_tag_at(sql, index)
335
+ match = sql[index..]&.match(/\A\$[A-Za-z_][A-Za-z0-9_]*\$|\A\$\$/)
336
+ match&.[](0)
337
+ end
338
+
339
+ def missing_schema_migrations_table?(error)
340
+ message = error.message.to_s
341
+ MISSING_MIGRATIONS_TABLE_MESSAGES.any? { |pattern| message.match?(pattern) }
342
+ end
343
+
344
+ def quote(identifier, dialect)
345
+ BetterAuth::Schema::SQL.quote(identifier, dialect)
346
+ end
347
+
348
+ def literal(value)
349
+ "'#{value.to_s.gsub("'", "''")}'"
350
+ end
351
+
352
+ def normalize_dialect(value)
353
+ dialect = case value.to_s.downcase
354
+ when "postgresql"
355
+ :postgres
356
+ when "sqlite3"
357
+ :sqlite
358
+ else
359
+ value.to_sym
360
+ end
361
+ return dialect if [:postgres, :sqlite, :mysql, :mssql].include?(dialect)
362
+
363
+ raise UnsupportedAdapterError, "Unsupported SQL dialect for Better Auth migrations: #{dialect}"
364
+ end
365
+
366
+ def current_schema(connection, dialect)
367
+ case dialect
368
+ when :sqlite
369
+ sqlite_schema(connection)
370
+ when :postgres
371
+ information_schema(connection, dialect, postgres_columns_sql, postgres_indexes_sql)
372
+ when :mysql
373
+ information_schema(connection, dialect, mysql_columns_sql, mysql_indexes_sql)
374
+ when :mssql
375
+ information_schema(connection, dialect, mssql_columns_sql, mssql_indexes_sql)
376
+ else
377
+ raise UnsupportedAdapterError, "Unsupported SQL dialect for Better Auth migrations: #{dialect}"
378
+ end
379
+ end
380
+
381
+ def sqlite_schema(connection)
382
+ table_rows = execute_sql(connection, "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%';")
383
+ table_rows.each_with_object({}) do |row, schema|
384
+ table_name = row["name"] || row[:name]
385
+ columns = execute_sql(connection, "PRAGMA table_info(#{quote(table_name, :sqlite)});").each_with_object({}) do |column, result|
386
+ result[column["name"] || column[:name]] = column["type"] || column[:type]
387
+ end
388
+ indexes = empty_index_metadata
389
+ execute_sql(connection, "PRAGMA index_list(#{quote(table_name, :sqlite)});").each do |index|
390
+ index_name = index["name"] || index[:name]
391
+ unique = (index["unique"] || index[:unique]).to_i == 1
392
+ indexes[:names] << index_name
393
+ execute_sql(connection, "PRAGMA index_info(#{quote(index_name, :sqlite)});").each do |field|
394
+ column = field["name"] || field[:name]
395
+ indexes[:columns] << column
396
+ indexes[:unique_columns] << column if unique
397
+ end
398
+ end
399
+ schema[table_name] = {name: table_name, columns: columns, indexes: indexes}
400
+ end
401
+ end
402
+
403
+ def information_schema(connection, dialect, columns_sql, indexes_sql)
404
+ schema = {}
405
+ execute_sql(connection, columns_sql).each do |row|
406
+ table_name = row_value(row, "table_name")
407
+ column_name = row_value(row, "column_name")
408
+ data_type = row_value(row, "data_type")
409
+ schema[table_name] ||= {name: table_name, columns: {}, indexes: empty_index_metadata}
410
+ schema[table_name][:columns][column_name] = data_type
411
+ end
412
+ execute_sql(connection, indexes_sql).each do |row|
413
+ table_name = row_value(row, "table_name")
414
+ column_name = row_value(row, "column_name")
415
+ index_name = row_value(row, "index_name")
416
+ unique_value = row_value(row, "unique") || row_value(row, "is_unique")
417
+ non_unique_value = row_value(row, "non_unique")
418
+ unique = (!unique_value.nil?) ? truthy_database_value?(unique_value) : non_unique_value.to_i == 0
419
+ schema[table_name] ||= {name: table_name, columns: {}, indexes: empty_index_metadata}
420
+ schema[table_name][:indexes][:names] << index_name if index_name
421
+ schema[table_name][:indexes][:columns] << column_name if column_name
422
+ schema[table_name][:indexes][:unique_columns] << column_name if column_name && !!unique
423
+ end
424
+ schema
425
+ end
426
+
427
+ def row_value(row, key)
428
+ candidates = [
429
+ key,
430
+ key.to_sym,
431
+ key.upcase,
432
+ key.upcase.to_sym,
433
+ camelize_lower(key),
434
+ camelize_lower(key).to_sym
435
+ ]
436
+ candidates.each { |candidate| return row[candidate] if row.key?(candidate) }
437
+ nil
438
+ end
439
+
440
+ def truthy_database_value?(value)
441
+ return value if value == true || value == false
442
+
443
+ value.to_i != 0
444
+ end
445
+
446
+ def camelize_lower(value)
447
+ parts = value.to_s.split("_")
448
+ ([parts.first] + parts.drop(1).map(&:capitalize)).join
449
+ end
450
+
451
+ def empty_index_metadata
452
+ {names: Set.new, columns: Set.new, unique_columns: Set.new}
453
+ end
454
+
455
+ def index_change(table_name, column, attributes, dialect:, unique:)
456
+ BetterAuth::MigrationPlan::IndexChange.new(
457
+ table_name: table_name,
458
+ field_name: column,
459
+ name: index_name(table_name, column, attributes, dialect),
460
+ unique: unique,
461
+ field: attributes
462
+ )
463
+ end
464
+
465
+ def indexable_field?(attributes, dialect)
466
+ attributes[:index] || filtered_unique_index?(attributes, dialect)
467
+ end
468
+
469
+ def filtered_unique_index?(attributes, dialect)
470
+ dialect == :mssql && attributes[:unique] && !attributes[:required]
471
+ end
472
+
473
+ def index_name(table_name, column, attributes, dialect)
474
+ filtered_unique_index?(attributes, dialect) ? "uniq_#{table_name}_#{column}" : "index_#{table_name}_on_#{column}"
475
+ end
476
+
477
+ def index_present?(table, column, unique:)
478
+ indexes = table.fetch(:indexes)
479
+ if unique
480
+ indexes[:unique_columns].include?(column) || indexes[:names].include?("index_#{table.fetch(:name, "")}_on_#{column}")
481
+ else
482
+ indexes[:columns].include?(column)
483
+ end
484
+ end
485
+
486
+ def matching_type?(actual_type, expected_type, dialect)
487
+ normalized = actual_type.to_s.downcase.split("(").first.strip
488
+ type = expected_type.to_s
489
+ expected = case type
490
+ when "string"
491
+ %w[text varchar character varying char uuid nvarchar uniqueidentifier]
492
+ when "number"
493
+ %w[integer int int4 bigint smallint numeric real decimal float double]
494
+ when "boolean"
495
+ %w[bool boolean integer tinyint bit smallint]
496
+ when "date"
497
+ %w[timestamptz timestamp date datetime datetime2]
498
+ when "json", "string[]", "number[]"
499
+ (dialect == :postgres) ? %w[json jsonb text] : %w[json text varchar nvarchar]
500
+ else
501
+ [normalized]
502
+ end
503
+ expected.include?(normalized)
504
+ end
505
+
506
+ def postgres_columns_sql
507
+ <<~SQL
508
+ SELECT table_name, column_name, data_type
509
+ FROM information_schema.columns
510
+ WHERE table_schema = current_schema();
511
+ SQL
512
+ end
513
+
514
+ def postgres_indexes_sql
515
+ <<~SQL
516
+ SELECT t.relname AS table_name, a.attname AS column_name, i.relname AS index_name, ix.indisunique AS unique
517
+ FROM pg_class t
518
+ JOIN pg_index ix ON t.oid = ix.indrelid
519
+ JOIN pg_class i ON i.oid = ix.indexrelid
520
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
521
+ JOIN pg_namespace n ON n.oid = t.relnamespace
522
+ WHERE t.relkind = 'r' AND n.nspname = current_schema();
523
+ SQL
524
+ end
525
+
526
+ def mysql_columns_sql
527
+ <<~SQL
528
+ SELECT table_name, column_name, data_type
529
+ FROM information_schema.columns
530
+ WHERE table_schema = DATABASE();
531
+ SQL
532
+ end
533
+
534
+ def mysql_indexes_sql
535
+ <<~SQL
536
+ SELECT table_name, column_name, index_name, CASE WHEN non_unique = 0 THEN 1 ELSE 0 END AS is_unique
537
+ FROM information_schema.statistics
538
+ WHERE table_schema = DATABASE();
539
+ SQL
540
+ end
541
+
542
+ def mssql_columns_sql
543
+ <<~SQL
544
+ SELECT t.name AS table_name, c.name AS column_name, ty.name AS data_type
545
+ FROM sys.tables t
546
+ JOIN sys.columns c ON c.object_id = t.object_id
547
+ JOIN sys.types ty ON ty.user_type_id = c.user_type_id;
548
+ SQL
549
+ end
550
+
551
+ def mssql_indexes_sql
552
+ <<~SQL
553
+ SELECT t.name AS table_name, c.name AS column_name, i.name AS index_name, i.is_unique
554
+ FROM sys.tables t
555
+ JOIN sys.indexes i ON i.object_id = t.object_id
556
+ JOIN sys.index_columns ic ON ic.object_id = t.object_id AND ic.index_id = i.index_id
557
+ JOIN sys.columns c ON c.object_id = t.object_id AND c.column_id = ic.column_id
558
+ WHERE i.name IS NOT NULL;
559
+ SQL
560
+ end
561
+
562
+ def physical_name(value)
563
+ BetterAuth::Schema.send(:physical_name, value)
564
+ end
565
+ end
566
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BetterAuth
4
- VERSION = "0.8.0"
4
+ VERSION = "0.10.0"
5
5
  end
data/lib/better_auth.rb CHANGED
@@ -15,12 +15,15 @@ require_relative "better_auth/async"
15
15
  require_relative "better_auth/deprecate"
16
16
  require_relative "better_auth/logger"
17
17
  require_relative "better_auth/instrumentation"
18
+ require_relative "better_auth/http_client"
18
19
  require_relative "better_auth/oauth2"
19
20
  require_relative "better_auth/password"
20
21
  require_relative "better_auth/plugin"
21
22
  require_relative "better_auth/configuration"
22
23
  require_relative "better_auth/schema"
23
24
  require_relative "better_auth/schema/sql"
25
+ require_relative "better_auth/migration_plan"
26
+ require_relative "better_auth/doctor"
24
27
  require_relative "better_auth/adapters/base"
25
28
  require_relative "better_auth/adapters/join_support"
26
29
  require_relative "better_auth/adapters/memory"