better_auth 0.8.0 → 0.11.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +142 -95
  5. data/lib/better_auth/adapters/base.rb +64 -1
  6. data/lib/better_auth/adapters/internal_adapter.rb +197 -11
  7. data/lib/better_auth/adapters/join_support.rb +10 -0
  8. data/lib/better_auth/adapters/memory.rb +303 -54
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +390 -63
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +48 -47
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +119 -15
  15. data/lib/better_auth/core.rb +0 -2
  16. data/lib/better_auth/crypto.rb +4 -3
  17. data/lib/better_auth/doctor.rb +108 -0
  18. data/lib/better_auth/endpoint.rb +88 -5
  19. data/lib/better_auth/error.rb +3 -1
  20. data/lib/better_auth/http_client.rb +46 -0
  21. data/lib/better_auth/logger.rb +1 -1
  22. data/lib/better_auth/middleware/origin_check.rb +9 -1
  23. data/lib/better_auth/migration_plan.rb +15 -0
  24. data/lib/better_auth/oauth2.rb +1 -1
  25. data/lib/better_auth/oauth_state.rb +147 -0
  26. data/lib/better_auth/plugin_loader.rb +220 -0
  27. data/lib/better_auth/plugins/access.rb +10 -13
  28. data/lib/better_auth/plugins/admin.rb +85 -29
  29. data/lib/better_auth/plugins/anonymous.rb +2 -0
  30. data/lib/better_auth/plugins/api_key.rb +4 -7
  31. data/lib/better_auth/plugins/bearer.rb +8 -22
  32. data/lib/better_auth/plugins/captcha.rb +24 -6
  33. data/lib/better_auth/plugins/device_authorization.rb +77 -5
  34. data/lib/better_auth/plugins/dub.rb +10 -2
  35. data/lib/better_auth/plugins/email_otp.rb +30 -47
  36. data/lib/better_auth/plugins/generic_oauth.rb +162 -140
  37. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  38. data/lib/better_auth/plugins/i18n.rb +215 -0
  39. data/lib/better_auth/plugins/jwt.rb +44 -19
  40. data/lib/better_auth/plugins/magic_link.rb +18 -21
  41. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  42. data/lib/better_auth/plugins/oauth_protocol.rb +233 -46
  43. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  44. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  45. data/lib/better_auth/plugins/one_tap.rb +78 -65
  46. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  47. data/lib/better_auth/plugins/open_api.rb +77 -9
  48. data/lib/better_auth/plugins/organization.rb +546 -102
  49. data/lib/better_auth/plugins/passkey.rb +4 -7
  50. data/lib/better_auth/plugins/phone_number.rb +25 -27
  51. data/lib/better_auth/plugins/scim.rb +4 -7
  52. data/lib/better_auth/plugins/siwe.rb +126 -16
  53. data/lib/better_auth/plugins/sso.rb +4 -7
  54. data/lib/better_auth/plugins/stripe.rb +4 -7
  55. data/lib/better_auth/plugins/two_factor.rb +205 -19
  56. data/lib/better_auth/plugins/username.rb +56 -13
  57. data/lib/better_auth/plugins.rb +57 -0
  58. data/lib/better_auth/rate_limiter.rb +300 -133
  59. data/lib/better_auth/request_ip.rb +123 -13
  60. data/lib/better_auth/router.rb +3 -3
  61. data/lib/better_auth/routes/account.rb +44 -8
  62. data/lib/better_auth/routes/email_verification.rb +93 -6
  63. data/lib/better_auth/routes/password.rb +6 -6
  64. data/lib/better_auth/routes/session.rb +7 -2
  65. data/lib/better_auth/routes/sign_in.rb +10 -1
  66. data/lib/better_auth/routes/sign_up.rb +45 -22
  67. data/lib/better_auth/routes/social.rb +197 -54
  68. data/lib/better_auth/routes/user.rb +17 -10
  69. data/lib/better_auth/schema/sql.rb +189 -34
  70. data/lib/better_auth/schema.rb +159 -5
  71. data/lib/better_auth/session.rb +29 -10
  72. data/lib/better_auth/session_store.rb +1 -1
  73. data/lib/better_auth/social_providers/apple.rb +6 -1
  74. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  75. data/lib/better_auth/social_providers/base.rb +45 -23
  76. data/lib/better_auth/social_providers/cognito.rb +75 -5
  77. data/lib/better_auth/social_providers/facebook.rb +95 -1
  78. data/lib/better_auth/social_providers/figma.rb +2 -0
  79. data/lib/better_auth/social_providers/github.rb +2 -0
  80. data/lib/better_auth/social_providers/google.rb +12 -1
  81. data/lib/better_auth/social_providers/line.rb +35 -1
  82. data/lib/better_auth/social_providers/linear.rb +1 -0
  83. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  84. data/lib/better_auth/social_providers/naver.rb +24 -1
  85. data/lib/better_auth/social_providers/notion.rb +1 -0
  86. data/lib/better_auth/social_providers/paybin.rb +25 -1
  87. data/lib/better_auth/social_providers/paypal.rb +118 -2
  88. data/lib/better_auth/social_providers/reddit.rb +25 -4
  89. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  90. data/lib/better_auth/social_providers/twitch.rb +24 -1
  91. data/lib/better_auth/social_providers/twitter.rb +36 -1
  92. data/lib/better_auth/social_providers/vk.rb +34 -1
  93. data/lib/better_auth/social_providers/wechat.rb +6 -4
  94. data/lib/better_auth/sql_migration.rb +566 -0
  95. data/lib/better_auth/version.rb +1 -1
  96. data/lib/better_auth.rb +14 -41
  97. metadata +14 -18
  98. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  99. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  100. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  101. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  102. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  103. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  104. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  105. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  106. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  107. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  108. data/lib/better_auth/plugins/mcp.rb +0 -215
  109. data/lib/better_auth/plugins/oidc_provider.rb +0 -710
@@ -2,21 +2,52 @@
2
2
 
3
3
  require "securerandom"
4
4
  require "time"
5
+ require "monitor"
5
6
 
6
7
  module BetterAuth
7
8
  module Adapters
8
9
  class Memory < Base
10
+ include JoinSupport
11
+
12
+ LOCK_REGISTRY_GUARD = Mutex.new
13
+ LOCKS_BY_DATABASE = {}
14
+
15
+ class << self
16
+ def lock_for(database)
17
+ LOCK_REGISTRY_GUARD.synchronize do
18
+ LOCKS_BY_DATABASE[database.object_id] ||= Monitor.new
19
+ end
20
+ end
21
+ end
22
+
9
23
  attr_reader :db
10
24
 
11
25
  def initialize(options, db = nil)
12
26
  super(options)
13
27
  @db = db || build_db
28
+ @lock = self.class.lock_for(@db)
14
29
  end
15
30
 
16
31
  def create(model:, data:, force_allow_id: false)
17
- model = model.to_s
18
- table_for(model) << transform_input(model, data, "create", force_allow_id)
19
- table_for(model).last
32
+ @lock.synchronize do
33
+ model = model.to_s
34
+ table_for(model) << transform_input(model, data, "create", force_allow_id)
35
+ table_for(model).last
36
+ end
37
+ end
38
+
39
+ def create_if_absent(model:, data:, conflict_field: "id", force_allow_id: true)
40
+ @lock.synchronize do
41
+ model = model.to_s
42
+ fields = Schema.auth_tables(options).fetch(model).fetch(:fields)
43
+ field = atomic_schema_field(fields, conflict_field)
44
+ input = transform_input(model, data, "create", force_allow_id)
45
+ raise APIError.new("BAD_REQUEST", message: "Missing conflict field #{conflict_field}") unless input.key?(field)
46
+ next false if table_for(model).any? { |record| record[field] == input[field] }
47
+
48
+ table_for(model) << input
49
+ true
50
+ end
20
51
  end
21
52
 
22
53
  def find_one(model:, where: [], select: nil, join: nil)
@@ -24,28 +55,42 @@ module BetterAuth
24
55
  end
25
56
 
26
57
  def find_many(model:, where: [], sort_by: nil, limit: nil, offset: nil, select: nil, join: nil)
27
- model = model.to_s
28
- records = table_for(model).select { |record| matches_where?(record, where || []) }.map(&:dup)
29
- records = records.map { |record| apply_join(model, record, join) } if join
30
- records = sort_records(model, records, sort_by) if sort_by
31
- records = records.drop(offset.to_i) if offset
32
- records = records.first(limit.to_i) if limit
33
- records = records.map { |record| select_fields(model, record, select) } if select && !select.empty?
34
- records
58
+ @lock.synchronize do
59
+ model = model.to_s
60
+ records = table_for(model).select { |record| matches_where?(record, where || []) }.map(&:dup)
61
+ records = records.map { |record| apply_join(model, record, join) } if join
62
+ records = sort_records(model, records, sort_by) if sort_by
63
+ records = records.drop(offset.to_i) if offset
64
+ records = records.first(limit.to_i) if limit
65
+ records = records.map { |record| select_fields(model, record, select, join: join) } if select && !select.empty?
66
+ records
67
+ end
35
68
  end
36
69
 
37
70
  def update(model:, where:, update:)
38
- records = table_for(model).select { |record| matches_where?(record, where || []) }
39
- data = transform_input(model.to_s, update, "update", true)
40
- records.each { |record| record.merge!(data) }
41
- records.first
71
+ @lock.synchronize do
72
+ model = model.to_s
73
+ next nil if Array(where).empty?
74
+
75
+ ensure_update_input_has_fields!(model, update)
76
+ records = table_for(model).select { |record| matches_where?(record, where) }
77
+ data = transform_input(model, update, "update", true)
78
+ ensure_update_data!(data)
79
+ records.each { |record| record.merge!(data) }
80
+ records.first
81
+ end
42
82
  end
43
83
 
44
84
  def update_many(model:, where:, update:)
45
- records = table_for(model).select { |record| matches_where?(record, where || []) }
46
- data = transform_input(model.to_s, update, "update", true)
47
- records.each { |record| record.merge!(data) }
48
- records.first
85
+ @lock.synchronize do
86
+ model = model.to_s
87
+ ensure_update_input_has_fields!(model, update)
88
+ records = table_for(model).select { |record| matches_where?(record, where || []) }
89
+ data = transform_input(model, update, "update", true)
90
+ ensure_update_data!(data)
91
+ records.each { |record| record.merge!(data) }
92
+ records.length
93
+ end
49
94
  end
50
95
 
51
96
  def delete(model:, where:)
@@ -54,26 +99,103 @@ module BetterAuth
54
99
  end
55
100
 
56
101
  def delete_many(model:, where:)
57
- table = table_for(model)
58
- matches = table.select { |record| matches_where?(record, where || []) }
59
- @db[model.to_s] = table.reject { |record| matches.include?(record) }
60
- matches.length
102
+ @lock.synchronize do
103
+ table = table_for(model)
104
+ matches = table.select { |record| matches_where?(record, where || []) }
105
+ @db[model.to_s] = table.reject { |record| matches.include?(record) }
106
+ matches.length
107
+ end
61
108
  end
62
109
 
63
110
  def count(model:, where: nil)
64
111
  find_many(model: model, where: where || []).length
65
112
  end
66
113
 
114
+ def consume_one(model:, where:)
115
+ @lock.synchronize do
116
+ table = table_for(model)
117
+ index = table.index { |record| matches_where?(record, where || []) }
118
+ index ? table.delete_at(index) : nil
119
+ end
120
+ end
121
+
122
+ def increment_one(model:, where:, increment:, set: nil, allow_server_managed: false)
123
+ @lock.synchronize do
124
+ model = model.to_s
125
+ increments = normalized_increments(model, increment, allow_server_managed)
126
+ assignments = normalized_increment_set(model, set)
127
+ raise APIError.new("BAD_REQUEST", message: "increment_one requires a non-empty increment or set") if increments.empty? && assignments.empty?
128
+
129
+ target = table_for(model).find { |record| matches_where?(record, where || []) }
130
+ next nil unless target
131
+
132
+ increments.each do |field, delta|
133
+ current = target[field]
134
+ target[field] = (current.is_a?(Numeric) ? current : 0) + delta
135
+ end
136
+ target.merge!(assignments)
137
+ target
138
+ end
139
+ end
140
+
67
141
  def transaction
68
- snapshot = Marshal.load(Marshal.dump(db))
69
- yield self
70
- rescue
71
- @db = snapshot
72
- raise
142
+ return yield active_transaction_adapter if active_transaction_adapter
143
+
144
+ @lock.synchronize do
145
+ snapshot = transaction_snapshot(db)
146
+ begin
147
+ with_transaction_context(self) { yield self }
148
+ rescue
149
+ db.replace(snapshot)
150
+ raise
151
+ end
152
+ end
73
153
  end
74
154
 
75
155
  private
76
156
 
157
+ def transaction_snapshot(value, copies = {})
158
+ object_id = value.object_id
159
+ return copies.fetch(object_id) if copies.key?(object_id)
160
+
161
+ copy = case value
162
+ when Hash
163
+ value.dup.tap do |hash|
164
+ hash.clear
165
+ copies[object_id] = hash
166
+ if value.default_proc
167
+ hash.default_proc = value.default_proc
168
+ else
169
+ hash.default = transaction_snapshot(value.default, copies)
170
+ end
171
+ value.each do |key, child|
172
+ hash[transaction_snapshot(key, copies)] = transaction_snapshot(child, copies)
173
+ end
174
+ end
175
+ when Array
176
+ value.dup.tap do |array|
177
+ array.clear
178
+ copies[object_id] = array
179
+ value.each { |child| array << transaction_snapshot(child, copies) }
180
+ end
181
+ when Struct
182
+ value.dup.tap do |struct|
183
+ copies[object_id] = struct
184
+ value.each_pair do |member, child|
185
+ struct[member] = transaction_snapshot(child, copies)
186
+ end
187
+ end
188
+ when String, Time
189
+ value.dup.tap { |object| copies[object_id] = object }
190
+ when Proc, Method, UnboundMethod, NilClass, TrueClass, FalseClass, Symbol, Numeric
191
+ return value
192
+ else
193
+ raise TypeError, "Unsupported Memory transaction snapshot value: #{value.class}"
194
+ end
195
+ copy.freeze if value.frozen?
196
+ copy
197
+ end
198
+
77
199
  def build_db
78
200
  Schema.auth_tables(options).keys.to_h { |model| [model, []] }
79
201
  end
@@ -109,16 +231,68 @@ module BetterAuth
109
231
  raise APIError.new("BAD_REQUEST", message: "#{field} is required") unless field == "id"
110
232
  end
111
233
 
112
- output[field] = coerce_value(value, attributes) if value_provided
234
+ output[field] = coerce_value(value, field, attributes) if value_provided
113
235
  end
114
236
 
115
- output["id"] = generated_id if action == "create" && !output.key?("id")
237
+ output["id"] = generated_id(model) if action == "create" && fields.key?("id") && (!output.key?("id") || output["id"].nil?)
116
238
  output
117
239
  end
118
240
 
119
- def generated_id
241
+ def ensure_update_data!(data)
242
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") if data.empty?
243
+ end
244
+
245
+ def ensure_update_input_has_fields!(model, update)
246
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") unless update.is_a?(Hash)
247
+
248
+ fields = Schema.auth_tables(options).fetch(model).fetch(:fields)
249
+ input = stringify_keys(update)
250
+ has_updatable_field = input.any? do |field, _value|
251
+ next false if field == "id" || field == "_id"
252
+
253
+ fields.key?(field) || fields.any? { |logical_field, attributes| Schema.storage_key(attributes[:field_name] || logical_field) == field }
254
+ end
255
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") unless has_updatable_field
256
+ end
257
+
258
+ def normalized_increments(model, increment, allow_server_managed)
259
+ raise APIError.new("BAD_REQUEST", message: "increment must be a Hash") unless increment.is_a?(Hash)
260
+
261
+ fields = Schema.auth_tables(options).fetch(model).fetch(:fields)
262
+ increment.each_with_object({}) do |(field, delta), result|
263
+ logical_field = atomic_schema_field(fields, field)
264
+ attributes = fields[logical_field]
265
+ valid_field = attributes && logical_field != "id" && attributes[:type] == "number"
266
+ valid_field = false if attributes && attributes[:input] == false && allow_server_managed != true
267
+ unless valid_field
268
+ raise APIError.new("BAD_REQUEST", message: "Invalid increment field #{field}; expected a mutable numeric field")
269
+ end
270
+ if !delta.is_a?(Numeric) || (delta.respond_to?(:finite?) && !delta.finite?)
271
+ raise APIError.new("BAD_REQUEST", message: "Increment delta for #{field} must be numeric")
272
+ end
273
+
274
+ result[logical_field] = delta
275
+ end
276
+ end
277
+
278
+ def normalized_increment_set(model, set)
279
+ return {} if set.nil? || set.empty?
280
+
281
+ ensure_update_input_has_fields!(model, set)
282
+ transform_input(model, set, "update", true)
283
+ end
284
+
285
+ def atomic_schema_field(fields, field)
286
+ candidate = Schema.storage_key(field)
287
+ return candidate if fields.key?(candidate)
288
+
289
+ fields.find { |logical, attributes| Schema.storage_key(attributes[:field_name] || logical) == candidate }&.first
290
+ end
291
+
292
+ def generated_id(model)
120
293
  generator = options.advanced.dig(:database, :generate_id)
121
294
  return generator.call.to_s if generator.respond_to?(:call)
295
+ return table_for(model).length + 1 if generator == "serial"
122
296
  return SecureRandom.uuid if generator == "uuid"
123
297
 
124
298
  SecureRandom.hex(16)
@@ -128,8 +302,9 @@ module BetterAuth
128
302
  default.respond_to?(:call) ? default.call : default
129
303
  end
130
304
 
131
- def coerce_value(value, attributes)
305
+ def coerce_value(value, field, attributes)
132
306
  return value if value.nil?
307
+ return coerce_number(value) if serial_id_field?(field, attributes)
133
308
  return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
134
309
 
135
310
  value
@@ -139,24 +314,19 @@ module BetterAuth
139
314
  clauses = Array(where)
140
315
  return true if clauses.empty?
141
316
 
142
- result = evaluate_clause(record, clauses.first)
143
- clauses.each do |clause|
144
- clause_result = evaluate_clause(record, clause)
145
- if fetch_key(clause, :connector).to_s.upcase == "OR"
146
- result ||= clause_result
147
- else
148
- result &&= clause_result
149
- end
150
- end
151
- result
317
+ and_clauses, or_clauses = grouped_where_clauses(clauses)
318
+ and_clauses.all? { |clause| evaluate_clause(record, clause) } &&
319
+ (or_clauses.empty? || or_clauses.any? { |clause| evaluate_clause(record, clause) })
152
320
  end
153
321
 
154
322
  def evaluate_clause(record, clause)
155
323
  field = Schema.storage_key(fetch_key(clause, :field))
156
324
  value = fetch_key(clause, :value)
157
325
  operator = (fetch_key(clause, :operator) || "eq").to_s
326
+ mode = (fetch_key(clause, :mode) || "sensitive").to_s
158
327
  current = record[field]
159
328
  comparable = coerce_where_value(record, field, value, operator)
329
+ current, comparable = insensitive_values(current, comparable) if insensitive_comparison?(mode, current, comparable)
160
330
 
161
331
  case operator
162
332
  when "in"
@@ -187,9 +357,9 @@ module BetterAuth
187
357
  def coerce_where_value(record, field, value, operator)
188
358
  attributes = schema_for_record_field(record, field)
189
359
  return value unless attributes
190
- return Array(value).map { |entry| coerce_scalar_where_value(entry, attributes) } if %w[in not_in].include?(operator)
360
+ return Array(value).map { |entry| coerce_scalar_where_value(entry, field, attributes) } if %w[in not_in].include?(operator)
191
361
 
192
- coerce_scalar_where_value(value, attributes)
362
+ coerce_scalar_where_value(value, field, attributes)
193
363
  end
194
364
 
195
365
  def schema_for_record_field(record, field)
@@ -201,8 +371,9 @@ module BetterAuth
201
371
  nil
202
372
  end
203
373
 
204
- def coerce_scalar_where_value(value, attributes)
374
+ def coerce_scalar_where_value(value, field, attributes)
205
375
  return value if value.nil?
376
+ return coerce_number(value) if serial_id_field?(field, attributes)
206
377
 
207
378
  case attributes[:type]
208
379
  when "boolean"
@@ -221,6 +392,32 @@ module BetterAuth
221
392
  value
222
393
  end
223
394
 
395
+ def serial_id_field?(field, attributes)
396
+ return false unless options.advanced.dig(:database, :generate_id) == "serial"
397
+ return true if field.to_s == "id"
398
+
399
+ reference = attributes[:references]
400
+ return false unless reference
401
+
402
+ (reference[:field] || reference["field"]).to_s == "id"
403
+ end
404
+
405
+ def insensitive_comparison?(mode, current, comparable)
406
+ return false unless mode == "insensitive"
407
+
408
+ current.is_a?(String) && (comparable.is_a?(String) || comparable.is_a?(Array))
409
+ end
410
+
411
+ def insensitive_values(current, comparable)
412
+ normalized_current = current.downcase
413
+ normalized_comparable = if comparable.is_a?(Array)
414
+ comparable.map { |entry| entry.is_a?(String) ? entry.downcase : entry }
415
+ else
416
+ comparable.downcase
417
+ end
418
+ [normalized_current, normalized_comparable]
419
+ end
420
+
224
421
  def coerce_number(value)
225
422
  return value unless value.is_a?(String)
226
423
  return value.to_i if /\A-?\d+\z/.match?(value)
@@ -245,25 +442,77 @@ module BetterAuth
245
442
  value.nil? ? "" : value
246
443
  end
247
444
 
248
- def select_fields(_model, record, select)
445
+ def select_fields(model, record, select, join: nil)
249
446
  fields = Array(select).map { |field| Schema.storage_key(field) }
250
- record.slice(*fields)
447
+ selected = record.slice(*fields)
448
+ normalized_join(model, join).each_key do |join_model|
449
+ selected[join_model] = record[join_model] if record.key?(join_model)
450
+ end
451
+ selected
251
452
  end
252
453
 
253
454
  def apply_join(model, record, join)
254
455
  joined = record.dup
255
- join.each_key do |join_model|
256
- join_model = join_model.to_s
257
- joined[join_model] = case [model, join_model]
258
- when ["session", "user"], ["account", "user"]
259
- table_for("user").find { |user| user["id"] == record["userId"] }
260
- when ["user", "account"]
261
- table_for("account").select { |account| account["userId"] == record["id"] }
456
+ normalized_join(model, join).each do |join_model, config|
457
+ matches = table_for(join_model).select do |join_record|
458
+ join_record[config.fetch(:to)] == record[config.fetch(:from)]
459
+ end.map(&:dup)
460
+
461
+ joined[join_model] = if one_to_one_join?(config)
462
+ matches.first
463
+ else
464
+ matches.first(join_limit(config))
262
465
  end
263
466
  end
264
467
  joined
265
468
  end
266
469
 
470
+ def one_to_one_join?(config)
471
+ config[:relation] == "one-to-one" || config[:unique] == true
472
+ end
473
+
474
+ def inferred_join_config(model, join_model)
475
+ foreign_keys = schema_for(join_model).fetch(:fields).select do |_field, attributes|
476
+ reference_model_matches?(attributes, model)
477
+ end
478
+ forward_join = true
479
+
480
+ if foreign_keys.empty?
481
+ foreign_keys = schema_for(model).fetch(:fields).select do |_field, attributes|
482
+ reference_model_matches?(attributes, join_model)
483
+ end
484
+ forward_join = false
485
+ end
486
+
487
+ raise Error, "No foreign key found for model #{join_model} and base model #{model} while performing join operation." if foreign_keys.empty?
488
+ raise Error, "Multiple foreign keys found for model #{join_model} and base model #{model} while performing join operation. Only one foreign key is supported." if foreign_keys.length > 1
489
+
490
+ foreign_key, attributes = foreign_keys.first
491
+ reference = attributes.fetch(:references)
492
+ if forward_join
493
+ unique = attributes[:unique] == true
494
+ {from: reference.fetch(:field).to_s, to: foreign_key, relation: unique ? "one-to-one" : "one-to-many", unique: unique}
495
+ else
496
+ {from: foreign_key, to: reference.fetch(:field).to_s, relation: "one-to-one", unique: true}
497
+ end
498
+ end
499
+
500
+ def schema_for(model)
501
+ Schema.auth_tables(options).fetch(model.to_s)
502
+ end
503
+
504
+ def reference_model_matches?(attributes, model)
505
+ reference = attributes[:references]
506
+ return false unless reference
507
+
508
+ reference_model = reference[:model] || reference["model"]
509
+ reference_model.to_s == model.to_s || reference_model.to_s == schema_for(model).fetch(:model_name)
510
+ end
511
+
512
+ def storage_key(field)
513
+ Schema.storage_key(field)
514
+ end
515
+
267
516
  def stringify_keys(data)
268
517
  data.each_with_object({}) do |(key, value), result|
269
518
  result[Schema.storage_key(key)] = value
@@ -17,16 +17,20 @@ module BetterAuth
17
17
  end
18
18
 
19
19
  def transaction
20
+ return yield active_transaction_adapter if active_transaction_adapter
20
21
  return super unless connection.respond_to?(:transaction)
21
22
 
22
- connection.transaction { yield self }
23
+ connection.transaction { with_transaction_context(self) { yield self } }
23
24
  end
24
25
 
25
26
  private
26
27
 
27
- def execute(sql, params)
28
+ def execute(sql, params, affected_rows_result: false)
28
29
  if connection.respond_to?(:fetch)
29
- connection.fetch(sql, *params).all.map { |row| stringify_row(row) }
30
+ dataset = connection.fetch(sql, *params)
31
+ return dataset.delete if affected_rows_result
32
+
33
+ dataset.all.map { |row| stringify_row(row) }
30
34
  else
31
35
  super
32
36
  end