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,6 +2,7 @@
2
2
 
3
3
  require "securerandom"
4
4
  require "json"
5
+ require "monitor"
5
6
  require "time"
6
7
 
7
8
  module BetterAuth
@@ -15,6 +16,7 @@ module BetterAuth
15
16
  super(options)
16
17
  @connection = connection
17
18
  @dialect = dialect.to_sym
19
+ @connection_lock = Monitor.new
18
20
  end
19
21
 
20
22
  def create(model:, data:, force_allow_id: false)
@@ -30,7 +32,36 @@ module BetterAuth
30
32
  row = rows.first
31
33
  return normalize_record(model, row) if row
32
34
 
33
- find_one(model: model, where: [{field: "id", value: input.fetch("id")}])
35
+ lookup = create_lookup(model, input)
36
+ lookup ? find_one(model: model, where: [lookup]) : input
37
+ end
38
+
39
+ def create_if_absent(model:, data:, conflict_field: "id", force_allow_id: true)
40
+ model = model.to_s
41
+ field = atomic_schema_field(schema_for(model).fetch(:fields), conflict_field)
42
+ input = transform_input(model, data, "create", force_allow_id)
43
+ raise APIError.new("BAD_REQUEST", message: "Missing conflict field #{conflict_field}") unless input.key?(field)
44
+
45
+ columns = input.keys.map { |key| storage_field(model, key) }
46
+ params = input.values
47
+ values = params.each_index.map { |index| placeholder(index + 1) }.join(", ")
48
+ table = quote(table_for(model))
49
+ conflict_column = quote(storage_field(model, field))
50
+ quoted_columns = columns.map { |column| quote(column) }.join(", ")
51
+
52
+ case dialect
53
+ when :postgres, :sqlite
54
+ sql = "INSERT INTO #{table} (#{quoted_columns}) VALUES (#{values}) ON CONFLICT (#{conflict_column}) DO NOTHING RETURNING #{conflict_column}"
55
+ !execute(sql, params).empty?
56
+ when :mysql
57
+ transaction { create_if_absent_mysql(model, field, input, table, quoted_columns, values, params) }
58
+ when :mssql
59
+ sql = "INSERT INTO #{table} (#{quoted_columns}) OUTPUT inserted.#{conflict_column} " \
60
+ "SELECT #{values} WHERE NOT EXISTS (SELECT 1 FROM #{table} WITH (UPDLOCK, HOLDLOCK) WHERE #{conflict_column} = #{placeholder(params.length + 1)})"
61
+ !execute(sql, params + [input.fetch(field)]).empty?
62
+ else
63
+ raise NotImplementedError, "create_if_absent is unsupported for #{dialect}"
64
+ end
34
65
  end
35
66
 
36
67
  def find_one(model:, where: [], select: nil, join: nil)
@@ -61,21 +92,29 @@ module BetterAuth
61
92
 
62
93
  def update(model:, where:, update:)
63
94
  model = model.to_s
95
+ return nil if Array(where).empty?
96
+
97
+ ensure_update_input_has_fields!(model, update)
64
98
  if dialect == :postgres
65
99
  records = update_many(model: model, where: where, update: update, returning: true)
66
100
  return records.is_a?(Array) ? records.first : records
67
101
  end
68
102
 
69
- existing = find_one(model: model, where: where, select: ["id"])
103
+ existing = find_one(model: model, where: where)
70
104
  return nil unless existing
71
105
 
72
- update_many(model: model, where: where, update: update)
73
- find_one(model: model, where: [{field: "id", value: existing.fetch("id")}])
106
+ updated_count = update_many(model: model, where: where, update: update)
107
+ return nil unless updated_count.to_i.positive?
108
+
109
+ lookup = record_lookup(model, existing)
110
+ lookup ? find_one(model: model, where: [lookup]) : find_one(model: model, where: where)
74
111
  end
75
112
 
76
113
  def update_many(model:, where:, update:, returning: false)
77
114
  model = model.to_s
115
+ ensure_update_input_has_fields!(model, update)
78
116
  data = transform_input(model, update, "update", true)
117
+ ensure_update_data!(data)
79
118
  params = []
80
119
  assignments = data.each_key.map do |field|
81
120
  params << data[field]
@@ -87,11 +126,11 @@ module BetterAuth
87
126
  sql << " SET "
88
127
  sql << assignments.join(", ")
89
128
  sql << " WHERE #{where_sql}" unless where_sql.empty?
90
- sql << " RETURNING *" if dialect == :postgres
91
- rows = execute(sql, params).map { |row| normalize_record(model, row) }
92
- return rows if returning || dialect == :postgres
129
+ sql << " RETURNING *" if dialect == :postgres && returning
130
+ result = execute(sql, params, affected_rows_result: !returning)
131
+ return result.map { |row| normalize_record(model, row) } if returning
93
132
 
94
- nil
133
+ affected_rows(result)
95
134
  end
96
135
 
97
136
  def delete(model:, where:)
@@ -99,14 +138,15 @@ module BetterAuth
99
138
  nil
100
139
  end
101
140
 
102
- def delete_many(model:, where:)
141
+ def delete_many(model:, where:, limit: nil)
103
142
  model = model.to_s
104
143
  params = []
105
144
  where_sql = build_where(model, where || [], params)
106
145
  sql = +"DELETE FROM "
107
146
  sql << quote(table_for(model))
108
147
  sql << " WHERE #{where_sql}" unless where_sql.empty?
109
- result = execute(sql, params)
148
+ sql << " LIMIT #{Integer(limit)}" if limit && dialect == :mysql
149
+ result = execute(sql, params, affected_rows_result: true)
110
150
  affected_rows(result)
111
151
  end
112
152
 
@@ -121,18 +161,217 @@ module BetterAuth
121
161
  (row["count"] || row[:count] || 0).to_i
122
162
  end
123
163
 
164
+ def consume_one(model:, where:)
165
+ model = model.to_s
166
+ case dialect
167
+ when :postgres, :sqlite
168
+ consume_one_with_returning(model, where)
169
+ when :mssql
170
+ consume_one_with_output(model, where)
171
+ else
172
+ transaction { consume_one_with_lock(model, where) }
173
+ end
174
+ end
175
+
176
+ def increment_one(model:, where:, increment:, set: nil, allow_server_managed: false)
177
+ model = model.to_s
178
+ increments, assignments = normalize_atomic_update(model, increment, set, allow_server_managed)
179
+ case dialect
180
+ when :postgres, :sqlite
181
+ increment_one_with_returning(model, where, increments, assignments)
182
+ when :mssql
183
+ increment_one_with_output(model, where, increments, assignments)
184
+ else
185
+ transaction { increment_one_with_lock(model, where, increments, assignments) }
186
+ end
187
+ end
188
+
124
189
  def transaction
125
- execute("BEGIN", [])
126
- result = yield self
127
- execute("COMMIT", [])
128
- result
129
- rescue
130
- execute("ROLLBACK", [])
131
- raise
190
+ return yield active_transaction_adapter if active_transaction_adapter
191
+
192
+ @connection_lock.synchronize do
193
+ execute("BEGIN", [])
194
+ result = with_transaction_context(self) { yield self }
195
+ execute("COMMIT", [])
196
+ result
197
+ rescue
198
+ execute("ROLLBACK", [])
199
+ raise
200
+ end
132
201
  end
133
202
 
134
203
  private
135
204
 
205
+ def consume_one_with_returning(model, where)
206
+ params = []
207
+ where_sql = build_where(model, where || [], params)
208
+ lookup = quote(storage_field(model, atomic_lookup_field(model)))
209
+ lock = (dialect == :postgres) ? " FOR UPDATE" : ""
210
+ table = quote(table_for(model))
211
+ sql = "DELETE FROM #{table} WHERE #{lookup} IN (SELECT #{lookup} FROM #{table}"
212
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
213
+ sql << " LIMIT 1#{lock}) RETURNING *"
214
+ normalize_record(model, execute(sql, params).first)
215
+ end
216
+
217
+ def consume_one_with_output(model, where)
218
+ params = []
219
+ where_sql = build_where(model, where || [], params)
220
+ sql = "DELETE TOP (1) FROM #{quote(table_for(model))} OUTPUT deleted.*"
221
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
222
+ normalize_record(model, execute(sql, params).first)
223
+ end
224
+
225
+ def consume_one_with_lock(model, where)
226
+ target = select_atomic_target(model, where)
227
+ return nil unless target
228
+
229
+ lookup = record_lookup(model, target)
230
+ raise Error, "#{self.class} cannot atomically identify a #{model} row" unless lookup
231
+
232
+ deleted = delete_many(model: model, where: [lookup], limit: 1)
233
+ ensure_numeric_affected_rows!(deleted, "delete_many")
234
+ deleted.positive? ? target : nil
235
+ end
236
+
237
+ def increment_one_with_returning(model, where, increments, assignments)
238
+ params = []
239
+ assignment_sql = atomic_assignments_sql(model, increments, assignments, params)
240
+ where_sql = build_where(model, where || [], params)
241
+ lookup = quote(storage_field(model, atomic_lookup_field(model)))
242
+ lock = (dialect == :postgres) ? " FOR UPDATE" : ""
243
+ table = quote(table_for(model))
244
+ sql = "UPDATE #{table} SET #{assignment_sql} WHERE #{lookup} IN (SELECT #{lookup} FROM #{table}"
245
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
246
+ sql << " LIMIT 1#{lock}) RETURNING *"
247
+ normalize_record(model, execute(sql, params).first)
248
+ end
249
+
250
+ def increment_one_with_output(model, where, increments, assignments)
251
+ params = []
252
+ assignment_sql = atomic_assignments_sql(model, increments, assignments, params)
253
+ where_sql = build_where(model, where || [], params)
254
+ sql = "UPDATE TOP (1) #{quote(table_for(model))} SET #{assignment_sql} OUTPUT inserted.*"
255
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
256
+ normalize_record(model, execute(sql, params).first)
257
+ end
258
+
259
+ def increment_one_with_lock(model, where, increments, assignments)
260
+ target = select_atomic_target(model, where)
261
+ return nil unless target
262
+
263
+ lookup = record_lookup(model, target)
264
+ raise Error, "#{self.class} cannot atomically identify a #{model} row" unless lookup
265
+
266
+ params = []
267
+ assignment_sql = atomic_assignments_sql(model, increments, assignments, params)
268
+ guarded_where = Array(where) + [lookup.merge(connector: "AND")]
269
+ where_sql = build_where(model, guarded_where, params)
270
+ sql = "UPDATE #{quote(table_for(model))} SET #{assignment_sql} WHERE #{where_sql} LIMIT 1"
271
+ affected = affected_rows(execute(sql, params, affected_rows_result: true))
272
+ ensure_numeric_affected_rows!(affected, "update")
273
+ find_one(model: model, where: [lookup])
274
+ end
275
+
276
+ def select_atomic_target(model, where)
277
+ params = []
278
+ where_sql = build_where(model, where || [], params)
279
+ sql = "SELECT #{select_sql(model, nil, nil)} FROM #{quote(table_for(model))}"
280
+ sql << " WHERE #{where_sql}" unless where_sql.empty?
281
+ sql << " LIMIT 1 FOR UPDATE"
282
+ normalize_record(model, execute(sql, params).first)
283
+ end
284
+
285
+ def normalize_atomic_update(model, increment, set, allow_server_managed)
286
+ raise APIError.new("BAD_REQUEST", message: "increment must be a Hash") unless increment.is_a?(Hash)
287
+
288
+ fields = schema_for(model).fetch(:fields)
289
+ increments = increment.each_with_object({}) do |(field, delta), result|
290
+ logical_field = atomic_schema_field(fields, field)
291
+ attributes = fields[logical_field]
292
+ valid_field = attributes && logical_field != "id" && attributes[:type] == "number"
293
+ valid_field = false if attributes && attributes[:input] == false && allow_server_managed != true
294
+ unless valid_field
295
+ raise APIError.new("BAD_REQUEST", message: "Invalid increment field #{field}; expected a mutable numeric field")
296
+ end
297
+ if !delta.is_a?(Numeric) || (delta.respond_to?(:finite?) && !delta.finite?)
298
+ raise APIError.new("BAD_REQUEST", message: "Increment delta for #{field} must be numeric")
299
+ end
300
+
301
+ result[logical_field] = delta
302
+ end
303
+ assignments = if set.nil? || set.empty?
304
+ {}
305
+ else
306
+ ensure_update_input_has_fields!(model, set)
307
+ transform_input(model, set, "update", true)
308
+ end
309
+ increments.reject! { |field, _delta| assignments.key?(field) }
310
+ if increments.empty? && assignments.empty?
311
+ raise APIError.new("BAD_REQUEST", message: "increment_one requires a non-empty increment or set")
312
+ end
313
+
314
+ [increments, assignments]
315
+ end
316
+
317
+ def atomic_assignments_sql(model, increments, assignments, params)
318
+ increment_sql = increments.map do |field, delta|
319
+ column = quote(storage_field(model, field))
320
+ params << delta
321
+ "#{column} = COALESCE(#{column}, 0) + #{placeholder(params.length)}"
322
+ end
323
+ set_sql = assignments.map do |field, value|
324
+ params << value
325
+ "#{quote(storage_field(model, field))} = #{placeholder(params.length)}"
326
+ end
327
+ (increment_sql + set_sql).join(", ")
328
+ end
329
+
330
+ def atomic_schema_field(fields, field)
331
+ candidate = storage_key(field)
332
+ return candidate if fields.key?(candidate)
333
+
334
+ fields.find { |logical, attributes| storage_key(attributes[:field_name] || logical) == candidate }&.first
335
+ end
336
+
337
+ def create_if_absent_mysql(model, field, input, table, columns, values, params)
338
+ savepoint = "better_auth_create_if_absent"
339
+ execute("SAVEPOINT #{savepoint}", [])
340
+ begin
341
+ execute("INSERT INTO #{table} (#{columns}) VALUES (#{values})", params)
342
+ execute("RELEASE SAVEPOINT #{savepoint}", [])
343
+ true
344
+ rescue => error
345
+ execute("ROLLBACK TO SAVEPOINT #{savepoint}", [])
346
+ execute("RELEASE SAVEPOINT #{savepoint}", [])
347
+ existing = find_one(model: model, where: [{field: field, value: input.fetch(field)}])
348
+ return false if mysql_duplicate_error?(error) && existing
349
+
350
+ raise error
351
+ end
352
+ end
353
+
354
+ def mysql_duplicate_error?(error)
355
+ (error.respond_to?(:error_number) && error.error_number == 1062) ||
356
+ (error.respond_to?(:errno) && error.errno == 1062)
357
+ end
358
+
359
+ def atomic_lookup_field(model)
360
+ fields = schema_for(model).fetch(:fields)
361
+ return "id" if fields.key?("id")
362
+
363
+ unique = fields.find { |_field, attributes| attributes[:unique] }
364
+ return unique.first if unique
365
+
366
+ raise Error, "#{self.class} cannot atomically identify a #{model} row without an id or unique field"
367
+ end
368
+
369
+ def ensure_numeric_affected_rows!(value, operation)
370
+ return value if value.is_a?(Numeric)
371
+
372
+ raise Error, "#{self.class} returned a non-numeric affected-row result from #{operation}"
373
+ end
374
+
136
375
  def transform_input(model, data, action, force_allow_id)
137
376
  fields = Schema.auth_tables(options).fetch(model).fetch(:fields)
138
377
  input = stringify_keys(data)
@@ -160,10 +399,47 @@ module BetterAuth
160
399
  output[field] = coerce_value(value, attributes) if value_provided
161
400
  end
162
401
 
163
- output["id"] = generated_id if action == "create" && !output.key?("id")
402
+ output["id"] = generated_id if action == "create" && !output.key?("id") && fields.key?("id")
164
403
  output
165
404
  end
166
405
 
406
+ def create_lookup(model, input)
407
+ fields = schema_for(model).fetch(:fields)
408
+ return {field: "id", value: input.fetch("id")} if fields.key?("id") && input.key?("id")
409
+
410
+ unique_field = fields.find { |field, attributes| attributes[:unique] && input.key?(field) }
411
+ return {field: unique_field.first, value: input.fetch(unique_field.first)} if unique_field
412
+
413
+ nil
414
+ end
415
+
416
+ def record_lookup(model, record)
417
+ fields = schema_for(model).fetch(:fields)
418
+ return {field: "id", value: record.fetch("id")} if fields.key?("id") && record.key?("id")
419
+
420
+ unique_field = fields.find { |field, attributes| attributes[:unique] && record.key?(field) }
421
+ return {field: unique_field.first, value: record.fetch(unique_field.first)} if unique_field
422
+
423
+ nil
424
+ end
425
+
426
+ def ensure_update_data!(data)
427
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") if data.empty?
428
+ end
429
+
430
+ def ensure_update_input_has_fields!(model, update)
431
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") unless update.is_a?(Hash)
432
+
433
+ fields = schema_for(model).fetch(:fields)
434
+ input = stringify_keys(update)
435
+ has_updatable_field = input.any? do |field, _value|
436
+ next false if field == "id" || field == "_id"
437
+
438
+ fields.key?(field) || fields.any? { |logical_field, attributes| storage_key(attributes[:field_name] || logical_field) == field }
439
+ end
440
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") unless has_updatable_field
441
+ end
442
+
167
443
  def select_sql(model, select, join)
168
444
  fields = Array(select).empty? ? schema_for(model).fetch(:fields).keys : Array(select).map { |field| storage_key(field) }
169
445
  columns = fields.map do |field|
@@ -220,39 +496,51 @@ module BetterAuth
220
496
  end
221
497
 
222
498
  def build_where(model, where, params)
223
- Array(where).each_with_index.map do |clause, index|
224
- field = storage_key(fetch_key(clause, :field))
225
- column = "#{quote(table_for(model))}.#{quote(storage_field(model, field))}"
226
- operator = (fetch_key(clause, :operator) || "eq").to_s
227
- value = fetch_key(clause, :value)
228
- attributes = schema_for(model).fetch(:fields).fetch(field)
229
-
230
- expression = case operator
499
+ and_clauses, or_clauses = grouped_where_clauses(where)
500
+ and_sql = and_clauses.map { |clause| build_where_clause(model, clause, params) }
501
+ or_sql = or_clauses.map { |clause| build_where_clause(model, clause, params) }
502
+ return and_sql.join(" AND ") if or_sql.empty?
503
+ return or_sql.join(" OR ") if and_sql.empty?
504
+
505
+ "(#{and_sql.join(" AND ")}) AND (#{or_sql.join(" OR ")})"
506
+ end
507
+
508
+ def build_where_clause(model, clause, params)
509
+ field = storage_key(fetch_key(clause, :field))
510
+ column = "#{quote(table_for(model))}.#{quote(storage_field(model, field))}"
511
+ operator = (fetch_key(clause, :operator) || "eq").to_s
512
+ value = fetch_key(clause, :value)
513
+ attributes = schema_for(model).fetch(:fields).fetch(field)
514
+ insensitive = insensitive_string_predicate?(clause, attributes)
515
+ predicate_column = insensitive ? "LOWER(#{column})" : column
516
+
517
+ if value.nil? && %w[eq ne].include?(operator)
518
+ null_operator = (operator == "ne") ? "IS NOT NULL" : "IS NULL"
519
+ "#{column} #{null_operator}"
520
+ else
521
+ case operator
231
522
  when "in", "not_in"
232
- values = Array(value).map { |entry| coerce_where_value(entry, attributes) }
523
+ values = Array(value).map { |entry| insensitive ? entry.to_s.downcase : coerce_where_value(entry, attributes) }
233
524
  placeholders = values.map do |entry|
234
525
  params << entry
235
526
  placeholder(params.length)
236
527
  end.join(", ")
237
528
  sql_operator = (operator == "not_in") ? "NOT IN" : "IN"
238
- "#{column} #{sql_operator} (#{placeholders})"
529
+ "#{predicate_column} #{sql_operator} (#{placeholders})"
239
530
  when "contains", "starts_with", "ends_with"
240
- escaped = escape_like(value)
531
+ escaped = escape_like(insensitive ? value.to_s.downcase : value)
241
532
  pattern = case operator
242
533
  when "starts_with" then "#{escaped}%"
243
534
  when "ends_with" then "%#{escaped}"
244
535
  else "%#{escaped}%"
245
536
  end
246
537
  params << pattern
247
- "#{column} LIKE #{placeholder(params.length)} ESCAPE #{escape_literal}"
538
+ "#{predicate_column} LIKE #{placeholder(params.length)} ESCAPE #{escape_literal}"
248
539
  else
249
- params << coerce_where_value(value, attributes)
250
- "#{column} #{sql_operator(operator)} #{placeholder(params.length)}"
540
+ params << (insensitive ? value.to_s.downcase : coerce_where_value(value, attributes))
541
+ "#{predicate_column} #{sql_operator(operator)} #{placeholder(params.length)}"
251
542
  end
252
-
253
- connector = (index.positive? && fetch_key(clause, :connector).to_s.upcase == "OR") ? "OR" : "AND"
254
- index.zero? ? expression : "#{connector} #{expression}"
255
- end.join(" ")
543
+ end
256
544
  end
257
545
 
258
546
  def order_sql(model, sort_by)
@@ -286,34 +574,66 @@ module BetterAuth
286
574
  }.fetch(operator, "=")
287
575
  end
288
576
 
289
- def execute(sql, params)
290
- if connection.respond_to?(:exec_params)
291
- result = connection.exec_params(sql, params)
292
- return result.to_a if result.respond_to?(:to_a)
293
-
294
- result
295
- elsif connection.respond_to?(:query) && params.empty?
296
- result = connection.query(sql)
297
- result.respond_to?(:to_a) ? result.to_a : result
298
- elsif connection.respond_to?(:prepare)
299
- statement = connection.prepare(sql)
300
- result = statement.execute(*params)
301
- result.respond_to?(:to_a) ? result.to_a : result
302
- elsif connection.respond_to?(:execute)
303
- result = connection.execute(sql, params)
304
- result.respond_to?(:to_a) ? result.to_a : result
305
- else
306
- raise Error, "SQL connection must respond to exec_params or prepare"
577
+ def insensitive_string_predicate?(clause, attributes)
578
+ fetch_key(clause, :mode).to_s == "insensitive" && attributes[:type] == "string"
579
+ end
580
+
581
+ def execute(sql, params, affected_rows_result: false)
582
+ @connection_lock.synchronize do
583
+ if connection.respond_to?(:exec_params)
584
+ result = connection.exec_params(sql, params)
585
+ return affected_rows_result ? result : [] if result.respond_to?(:fields) && result.fields.empty?
586
+ return result.to_a if result.respond_to?(:to_a)
587
+
588
+ result
589
+ elsif connection.respond_to?(:query) && params.empty?
590
+ result = connection.query(sql)
591
+ result.respond_to?(:to_a) ? result.to_a : result
592
+ elsif dialect == :sqlite && connection.respond_to?(:execute)
593
+ result = connection.execute(sql, params)
594
+ result.respond_to?(:to_a) ? result.to_a : result
595
+ elsif connection.respond_to?(:prepare)
596
+ statement = connection.prepare(sql)
597
+ result = nil
598
+ begin
599
+ result = statement.execute(*params)
600
+ if result.nil?
601
+ return [] unless affected_rows_result
602
+ return statement.affected_rows if statement.respond_to?(:affected_rows)
603
+ return connection.affected_rows if connection.respond_to?(:affected_rows)
604
+ end
605
+
606
+ rows = result.respond_to?(:to_a) ? result.to_a : result
607
+ rows
608
+ ensure
609
+ if result.respond_to?(:close)
610
+ result.close
611
+ elsif statement.respond_to?(:close)
612
+ statement.close
613
+ end
614
+ end
615
+ elsif connection.respond_to?(:execute)
616
+ result = connection.execute(sql, params)
617
+ result.respond_to?(:to_a) ? result.to_a : result
618
+ else
619
+ raise Error, "SQL connection must respond to exec_params or prepare"
620
+ end
307
621
  end
308
622
  end
309
623
 
310
624
  def affected_rows(result)
311
- return result.cmd_tuples if result.respond_to?(:cmd_tuples)
312
- return result.affected_rows if result.respond_to?(:affected_rows)
313
- return connection.changes if connection.respond_to?(:changes)
314
- return result.to_i if result.respond_to?(:to_i)
315
-
316
- 0
625
+ value = if result.respond_to?(:cmd_tuples)
626
+ result.cmd_tuples
627
+ elsif result.respond_to?(:affected_rows)
628
+ result.affected_rows
629
+ elsif result.is_a?(Numeric)
630
+ result
631
+ elsif connection.respond_to?(:affected_rows)
632
+ connection.affected_rows
633
+ elsif connection.respond_to?(:changes)
634
+ connection.changes
635
+ end
636
+ ensure_numeric_affected_rows!(value, "SQL mutation")
317
637
  end
318
638
 
319
639
  def normalize_record(model, row, join: nil)
@@ -360,6 +680,8 @@ module BetterAuth
360
680
  if config[:relation] == "one-to-one" || config[:unique] == true
361
681
  grouped[key][join_model] = joined
362
682
  else
683
+ next if grouped[key][join_model].length >= join_limit(config)
684
+
363
685
  grouped[key][join_model] << joined
364
686
  end
365
687
  end
@@ -406,11 +728,11 @@ module BetterAuth
406
728
  end
407
729
 
408
730
  def escape_like(value)
409
- value.to_s.gsub(/[\\%_]/) { |match| "\\#{match}" }
731
+ value.to_s.gsub(/[!%_]/) { |match| "!#{match}" }
410
732
  end
411
733
 
412
734
  def escape_literal
413
- (dialect == :postgres) ? "'\\\\'" : "'\\'"
735
+ "'!'"
414
736
  end
415
737
 
416
738
  def resolve_default(default)
@@ -420,9 +742,13 @@ module BetterAuth
420
742
  def coerce_value(value, attributes)
421
743
  return value if value.nil?
422
744
  return value ? 1 : 0 if dialect == :sqlite && attributes[:type] == "boolean"
423
- return value.iso8601(6) if dialect == :sqlite && attributes[:type] == "date" && value.respond_to?(:iso8601)
745
+ if dialect == :sqlite && attributes[:type] == "date"
746
+ value = Time.parse(value) if value.is_a?(String)
747
+ return value.iso8601(6) if value.respond_to?(:iso8601)
748
+ end
424
749
  return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
425
750
  return JSON.generate(value) if json_like?(attributes) && !value.is_a?(String)
751
+ return value.encode(Encoding::UTF_8) if attributes[:type] == "string" && value.is_a?(String) && value.encoding == Encoding::ASCII_8BIT
426
752
 
427
753
  value
428
754
  end
@@ -446,6 +772,7 @@ module BetterAuth
446
772
  def coerce_output_value(value, attributes)
447
773
  return value if value.nil?
448
774
  return coerce_boolean(value) if attributes[:type] == "boolean"
775
+ return coerce_number(value) if attributes[:type] == "number"
449
776
  return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
450
777
  return parse_json_value(value) if json_like?(attributes) && value.is_a?(String)
451
778
 
@@ -70,15 +70,7 @@ module BetterAuth
70
70
  def prepare_context_for_call!(request, headers)
71
71
  return unless context.respond_to?(:prepare_for_api_call!)
72
72
 
73
- source = direct_call_source(request, headers)
74
- if context.options.dynamic_base_url? && source.nil? && !dynamic_base_url_fallback?
75
- raise APIError.new(
76
- "INTERNAL_SERVER_ERROR",
77
- message: "Dynamic baseURL could not be resolved for this direct auth.api call. Pass `headers: request.headers` (or `request`) to the call, or add `fallback` to your baseURL config."
78
- )
79
- end
80
-
81
- context.prepare_for_api_call!(source)
73
+ context.prepare_for_api_call!(direct_call_source(request, headers))
82
74
  end
83
75
 
84
76
  def direct_call_source(request, headers)
@@ -88,13 +80,6 @@ module BetterAuth
88
80
  nil
89
81
  end
90
82
 
91
- def dynamic_base_url_fallback?
92
- config = context.options.base_url_config
93
- return false unless config.is_a?(Hash)
94
-
95
- !!(config[:fallback] || config["fallback"])
96
- end
97
-
98
83
  def run_endpoint_with_hooks(endpoint, endpoint_context)
99
84
  before = run_before_hooks(endpoint_context)
100
85
  return normalize_short_circuit(before, endpoint_context) if before
@@ -114,11 +99,7 @@ module BetterAuth
114
99
  endpoint_context.returned = result.response
115
100
  endpoint_context.response_headers = result.headers.dup
116
101
 
117
- after_result = run_after_hooks(endpoint_context)
118
- result.response = after_result.response
119
- result.headers = after_result.headers
120
- result.status = after_result.status if after_result.status
121
- result
102
+ run_after_hooks(endpoint_context)
122
103
  rescue APIError => error
123
104
  Endpoint::Result.new(response: error, status: error.status_code, headers: error.headers)
124
105
  end
@@ -163,9 +144,7 @@ module BetterAuth
163
144
  next unless hook_result
164
145
 
165
146
  normalized = Endpoint::Result.from_value(hook_result, endpoint_context)
166
- result.response = normalized.response
167
- result.status = normalized.status
168
- result.headers = normalized.headers
147
+ result = normalized
169
148
  endpoint_context.returned = result.response
170
149
  endpoint_context.response_headers = result.headers
171
150
  end