better_auth-mongodb 0.8.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4ed1669dc271afab7b9e15d5abd0fdd7aeb5228ad4b602eeda5438f8f07d5270
4
+ data.tar.gz: a6efaca422c47a3e24ffc7c6b2bcf0d7633843b76b0db514da8588bee4233090
5
+ SHA512:
6
+ metadata.gz: bb69f0da9105d78d720bdfca09f5c4e2fe99c9ee60a7f0ac392c4eab1f7613da8dc845ab23926499ff00107d455d8aa3a20fe2984fa6737fe603688cfc7bdde1
7
+ data.tar.gz: de14ab5fb5b7078d3803a5fed75c7415a195276b0dbde522845f5337e3194c0cac75534b49a66e794cf8ffec013d8d07926c3a0077594054848c56f2f79bf94c
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Rename the canonical Ruby gem to `better_auth-mongodb` while keeping
6
+ `better_auth-mongo-adapter` as a deprecated compatibility package.
7
+
8
+ ## 0.7.0 - 2026-05-05
9
+
10
+ - Added explicit `ensure_indexes!` setup helper for Mongo indexes derived from Better Auth schema metadata.
11
+ - Updated MongoDB setup docs to use the lambda adapter form, clearer standalone/replica-set transaction guidance, and production index guidance.
12
+ - Consolidated Mongo fake test support and strengthened transaction rollback coverage for staged mutations.
13
+ - Apply `advanced[:database][:default_find_many_limit]` to uncapped `find_many` calls and one-to-many Mongo `$lookup` joins, defaulting to 100 when omitted.
14
+ - Match upstream Mongo where-clause semantics for mixed connectors by bucketing multi-clause filters into `$and` and `$or` arrays instead of left-fold nesting.
15
+ - Allow scalar values for `in` and `not_in` filters as an intentional Ruby adapter-family adaptation.
16
+
17
+ ## 0.1.1 - 2026-04-30
18
+
19
+ - Fixed inferred limited joins so explicit relation and limit configuration is preserved.
20
+ - Added MongoDB upstream parity coverage using a fake Mongo adapter harness.
21
+
22
+ ## 0.1.0
23
+
24
+ - Extract MongoDB adapter support into the `better_auth-mongo-adapter` package.
25
+ - Align MongoDB adapter behavior with upstream Better Auth v1.6.9, including where-clause key variants, falsey value handling, ID normalization, and external adapter compatibility coverage.
data/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # better_auth-mongodb
2
+
3
+ MongoDB database adapter package for Better Auth Ruby.
4
+
5
+ ## Installation
6
+
7
+ Add the gem and require the package before configuring auth:
8
+
9
+ ```ruby
10
+ gem "better_auth-mongodb"
11
+ ```
12
+
13
+ ```ruby
14
+ require "mongo"
15
+ require "better_auth/mongodb"
16
+
17
+ mongo_client = Mongo::Client.new(ENV.fetch("BETTER_AUTH_MONGODB_URL"))
18
+
19
+ auth = BetterAuth.auth(
20
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
21
+ database: ->(options) {
22
+ BetterAuth::Adapters::MongoDB.new(
23
+ options,
24
+ database: mongo_client.database,
25
+ client: mongo_client,
26
+ transaction: false
27
+ )
28
+ }
29
+ )
30
+ ```
31
+
32
+ The lambda form lets Better Auth pass the final configuration into the adapter,
33
+ including plugins, custom schemas, and advanced database options.
34
+
35
+ ## Notes
36
+
37
+ This package depends on the official `mongo` gem. Keeping MongoDB support outside `better_auth` avoids installing MongoDB client dependencies for applications that only use SQL, Rails, Hanami, or in-memory storage.
38
+
39
+ The adapter stores Better Auth models in singular MongoDB collections by default, maps logical `id` values to Mongo `_id`, converts ObjectId-compatible ids through the Mongo driver, and supports the shared Better Auth database adapter contract.
40
+
41
+ Transactions are deployment-dependent. MongoDB multi-document transactions may
42
+ be unavailable on standalone servers and usually require a replica set plus
43
+ compatible driver/session settings. The setup example uses `transaction: false`;
44
+ enable transactions only when the MongoDB deployment supports them.
45
+
46
+ When using a replica set, remove `transaction: false` or pass
47
+ `transaction: true`. When using standalone local MongoDB, keep
48
+ `transaction: false`.
49
+
50
+ ## Indexes
51
+
52
+ MongoDB does not run SQL-style migrations. The adapter can create recommended
53
+ indexes from Better Auth schema metadata, but this is an explicit setup step:
54
+
55
+ ```ruby
56
+ adapter = BetterAuth::Adapters::MongoDB.new(
57
+ options,
58
+ database: mongo_client.database,
59
+ client: mongo_client,
60
+ transaction: false
61
+ )
62
+
63
+ adapter.ensure_indexes!
64
+ ```
65
+
66
+ `ensure_indexes!` creates indexes for schema fields marked `unique: true` or
67
+ `index: true`, including plugin schemas and custom model or field names. It
68
+ skips Mongo `_id` because MongoDB creates that index automatically. The method
69
+ returns a summary of requested indexes so deployment scripts can log what was
70
+ applied.
71
+
72
+ ## Limits
73
+
74
+ By default, `find_many` calls without an explicit `limit:` are capped at 100 records. Configure the default with Better Auth's advanced database option:
75
+
76
+ ```ruby
77
+ auth = BetterAuth.auth(
78
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
79
+ advanced: {
80
+ database: {
81
+ default_find_many_limit: 250
82
+ }
83
+ },
84
+ database: ->(options) {
85
+ BetterAuth::Adapters::MongoDB.new(
86
+ options,
87
+ database: mongo_client.database,
88
+ client: mongo_client,
89
+ transaction: false
90
+ )
91
+ }
92
+ )
93
+ ```
94
+
95
+ The same default applies to one-to-many join lookups when the join config does not set `limit:`. Passing an explicit `limit:` to `find_many` or to the join config overrides the default.
96
+
97
+ One-to-one joins ignore one-to-many limits. They are returned as a single object or `nil`.
98
+
99
+ Ruby's adapters accept scalar values for `in` and `not_in` filters and coerce
100
+ them to a one-element list. This is an intentional Ruby adapter-family behavior;
101
+ upstream's TypeScript adapter factory is stricter before the Mongo adapter sees
102
+ the query.
103
+
104
+ ## Compatibility
105
+
106
+ The older `better_auth-mongo-adapter` gem and `require "better_auth/mongo_adapter"`
107
+ entrypoint are deprecated compatibility shims. New applications should use
108
+ `better_auth-mongodb` and `require "better_auth/mongodb"`.
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ warn "better_auth/mongo_adapter is deprecated; use better_auth/mongodb instead.", uplevel: 1
4
+
5
+ require_relative "mongodb"
6
+
7
+ module BetterAuth
8
+ MongoAdapter = MongoDB unless const_defined?(:MongoAdapter, false)
9
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module MongoDB
5
+ VERSION = "0.8.0"
6
+ end
7
+ end
@@ -0,0 +1,682 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth"
4
+ require "mongo"
5
+ require "securerandom"
6
+ require "time"
7
+ require_relative "mongodb/version"
8
+
9
+ module BetterAuth
10
+ module Adapters
11
+ class MongoDB < Base
12
+ class MongoAdapterError < Error
13
+ attr_reader :code
14
+
15
+ def initialize(code, message)
16
+ @code = code
17
+ super(message)
18
+ end
19
+ end
20
+
21
+ attr_reader :database, :client, :use_plural
22
+
23
+ def initialize(options = nil, database:, client: nil, transaction: nil, use_plural: false, session: nil)
24
+ require "mongo" unless database
25
+
26
+ super(options || Configuration.new(secret: Configuration::DEFAULT_SECRET, database: :memory))
27
+ @database = database
28
+ @client = client
29
+ @transaction_enabled = transaction.nil? ? !client.nil? : !!transaction
30
+ @use_plural = !!use_plural
31
+ @session = session
32
+ end
33
+
34
+ def create(model:, data:, force_allow_id: false)
35
+ model = model.to_s
36
+ record = transform_input(model, data, "create", force_allow_id)
37
+ document = to_document(model, record)
38
+ collection_for(model).insert_one(document, session_options)
39
+ from_document(model, document)
40
+ end
41
+
42
+ def find_one(model:, where: [], select: nil, join: nil)
43
+ find_many(model: model, where: where, select: select, join: join, limit: 1).first
44
+ end
45
+
46
+ def find_many(model:, where: [], sort_by: nil, limit: nil, offset: nil, select: nil, join: nil)
47
+ model = model.to_s
48
+ pipeline = [{"$match" => mongo_filter(model, where || [])}]
49
+ pipeline.concat(join_stages(model, join)) if join
50
+ pipeline << {"$project" => projection_for(model, select, join)} if select && !select.empty?
51
+ pipeline << {"$sort" => {sort_field(model, sort_by) => sort_direction(sort_by)}} if sort_by
52
+ pipeline << {"$skip" => offset.to_i} if offset
53
+ effective_limit = limit.nil? ? default_find_many_limit : limit.to_i
54
+ pipeline << {"$limit" => effective_limit} if effective_limit.positive?
55
+
56
+ collection_for(model)
57
+ .aggregate(pipeline, session_options)
58
+ .to_a
59
+ .map { |document| from_document(model, stringify_document(document), join: join) }
60
+ end
61
+
62
+ def update(model:, where:, update:)
63
+ model = model.to_s
64
+ data = transform_input(model, update, "update", true)
65
+ document = to_document(model, data)
66
+ document.delete("_id")
67
+ result = collection_for(model).find_one_and_update(
68
+ mongo_filter(model, where || []),
69
+ {"$set" => document},
70
+ session_options.merge(return_document: :after)
71
+ )
72
+ result = unwrap_update_result(result)
73
+ result ? from_document(model, stringify_document(result)) : nil
74
+ end
75
+
76
+ def update_many(model:, where:, update:)
77
+ model = model.to_s
78
+ data = transform_input(model, update, "update", true)
79
+ document = to_document(model, data)
80
+ document.delete("_id")
81
+ result = collection_for(model).update_many(
82
+ mongo_filter(model, where || []),
83
+ {"$set" => document},
84
+ session_options
85
+ )
86
+ result.respond_to?(:modified_count) ? result.modified_count : result.to_i
87
+ end
88
+
89
+ def delete(model:, where:)
90
+ collection_for(model.to_s).delete_one(mongo_filter(model.to_s, where || []), session_options)
91
+ nil
92
+ end
93
+
94
+ def delete_many(model:, where:)
95
+ result = collection_for(model.to_s).delete_many(mongo_filter(model.to_s, where || []), session_options)
96
+ result.respond_to?(:deleted_count) ? result.deleted_count : result.to_i
97
+ end
98
+
99
+ def count(model:, where: nil)
100
+ pipeline = [
101
+ {"$match" => mongo_filter(model.to_s, where || [])},
102
+ {"$count" => "total"}
103
+ ]
104
+ row = collection_for(model.to_s).aggregate(pipeline, session_options).to_a.first
105
+ return 0 unless row
106
+
107
+ (row["total"] || row[:total] || 0).to_i
108
+ end
109
+
110
+ def ensure_indexes!
111
+ Schema.auth_tables(options).flat_map do |model, table|
112
+ table.fetch(:fields).filter_map do |field, attributes|
113
+ next if field == "id"
114
+ next unless attributes[:unique] || attributes[:index]
115
+
116
+ collection = collection_for(model)
117
+ key = storage_field(model, field)
118
+ index_options = attributes[:unique] ? {unique: true} : {}
119
+ collection.indexes.create_one({key => 1}, index_options)
120
+ {
121
+ collection: collection_name(model),
122
+ field: field,
123
+ keys: {key => 1},
124
+ unique: attributes[:unique] == true
125
+ }
126
+ end
127
+ end
128
+ end
129
+
130
+ def transaction
131
+ return yield self unless client && @transaction_enabled && client.respond_to?(:start_session)
132
+
133
+ session = client.start_session
134
+ begin
135
+ session.start_transaction
136
+ adapter = self.class.new(options, database: database, client: client, transaction: @transaction_enabled, use_plural: use_plural, session: session)
137
+ result = yield adapter
138
+ session.commit_transaction
139
+ result
140
+ rescue
141
+ session.abort_transaction
142
+ raise
143
+ ensure
144
+ session.end_session
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def transform_input(model, data, action, force_allow_id)
151
+ fields = fields_for(model)
152
+ input = stringify_keys(data)
153
+ output = {}
154
+
155
+ fields.each do |field, attributes|
156
+ next if field == "id" && input.key?(field) && !force_allow_id
157
+
158
+ value_provided = input.key?(field)
159
+ value = input[field]
160
+ if value_provided && attributes[:input] == false && value && !force_allow_id
161
+ raise APIError.new("BAD_REQUEST", message: "#{field} is not allowed to be set")
162
+ end
163
+
164
+ if !value_provided && action == "create" && attributes.key?(:default_value)
165
+ value = resolve_default(attributes[:default_value])
166
+ value_provided = true
167
+ elsif !value_provided && action == "update" && attributes[:on_update]
168
+ value = resolve_default(attributes[:on_update])
169
+ value_provided = true
170
+ end
171
+
172
+ if !value_provided && action == "create" && attributes[:required]
173
+ raise APIError.new("BAD_REQUEST", message: "#{field} is required") unless field == "id"
174
+ end
175
+ output[field] = coerce_value(value, attributes) if value_provided
176
+ end
177
+
178
+ output["id"] = generated_id if action == "create" && !output.key?("id")
179
+ output
180
+ end
181
+
182
+ def mongo_filter(model, where)
183
+ clauses = Array(where)
184
+ return {} if clauses.empty?
185
+
186
+ conditions = clauses.map do |clause|
187
+ connector = if fetch_key(clause, :connector).to_s.upcase == "OR"
188
+ "OR"
189
+ else
190
+ "AND"
191
+ end
192
+ {condition: condition_for(model, clause), connector: connector}
193
+ end
194
+ return conditions.first.fetch(:condition) if conditions.one?
195
+
196
+ result = {}
197
+ and_conditions = conditions.select { |entry| entry.fetch(:connector) == "AND" }.map { |entry| entry.fetch(:condition) }
198
+ or_conditions = conditions.select { |entry| entry.fetch(:connector) == "OR" }.map { |entry| entry.fetch(:condition) }
199
+ result["$and"] = and_conditions if and_conditions.any?
200
+ result["$or"] = or_conditions if or_conditions.any?
201
+ result
202
+ end
203
+
204
+ def default_find_many_limit
205
+ value = options.advanced.dig(:database, :default_find_many_limit)
206
+ return 100 if value.nil?
207
+
208
+ Integer(value)
209
+ rescue ArgumentError, TypeError
210
+ 100
211
+ end
212
+
213
+ def array_operator_values(value)
214
+ value.is_a?(Array) ? value : [value]
215
+ end
216
+
217
+ def condition_for(model, clause)
218
+ operator = (fetch_key(clause, :operator) || "eq").to_s.downcase
219
+ value = fetch_key(clause, :value)
220
+
221
+ field = resolve_field(model, fetch_key(clause, :field))
222
+ attributes = fields_for(model).fetch(field)
223
+ key = (field == "id") ? "_id" : storage_field(model, field)
224
+ mode = (fetch_key(clause, :mode) || "sensitive").to_s
225
+ id_field = id_field?(field, attributes)
226
+ insensitive = !id_field && mode == "insensitive" && insensitive_value?(value)
227
+ value = coerce_where_value(value, attributes)
228
+
229
+ case operator
230
+ when "eq"
231
+ (insensitive && value.is_a?(String)) ? regex_condition(key, value, :eq, insensitive: true) : {key => store_value(field, value, attributes, strict_id: true)}
232
+ when "in"
233
+ (insensitive && value.is_a?(Array)) ? insensitive_in_condition(key, value) : {key => {"$in" => array_operator_values(value).map { |entry| store_value(field, entry, attributes, strict_id: true) }}}
234
+ when "not_in"
235
+ (insensitive && value.is_a?(Array)) ? insensitive_not_in_condition(key, value) : {key => {"$nin" => array_operator_values(value).map { |entry| store_value(field, entry, attributes, strict_id: true) }}}
236
+ when "ne"
237
+ (insensitive && value.is_a?(String)) ? {key => {"$not" => regex_for(value, :eq, insensitive: true)}} : {key => {"$ne" => store_value(field, value, attributes, strict_id: true)}}
238
+ when "gt", "gte", "lt", "lte"
239
+ {key => {"$#{operator}" => store_value(field, value, attributes, strict_id: true)}}
240
+ when "contains", "starts_with", "ends_with"
241
+ regex_condition(key, value.to_s, operator.to_sym, insensitive: insensitive)
242
+ else
243
+ raise MongoAdapterError.new("UNSUPPORTED_OPERATOR", "Unsupported operator: #{operator}")
244
+ end
245
+ end
246
+
247
+ def insensitive_value?(value)
248
+ value.is_a?(String) || (value.is_a?(Array) && value.all? { |entry| entry.is_a?(String) })
249
+ end
250
+
251
+ def insensitive_in_condition(key, values)
252
+ return {"$expr" => {"$eq" => [1, 0]}} if values.empty?
253
+
254
+ {"$or" => values.map { |value| regex_condition(key, value, :eq, insensitive: true) }}
255
+ end
256
+
257
+ def insensitive_not_in_condition(key, values)
258
+ return {} if values.empty?
259
+
260
+ {"$nor" => values.map { |value| regex_condition(key, value, :eq, insensitive: true) }}
261
+ end
262
+
263
+ def regex_condition(key, value, operator, insensitive:)
264
+ {key => regex_for(value, operator, insensitive: insensitive)}
265
+ end
266
+
267
+ def regex_for(value, operator, insensitive:)
268
+ escaped = Regexp.escape(value.to_s[0, 256])
269
+ pattern = case operator.to_s
270
+ when "eq" then "\\A#{escaped}\\z"
271
+ when "starts_with" then "\\A#{escaped}"
272
+ when "ends_with" then "#{escaped}\\z"
273
+ else escaped
274
+ end
275
+ Regexp.new(pattern, insensitive ? Regexp::IGNORECASE : nil)
276
+ end
277
+
278
+ def join_stages(model, join)
279
+ normalized_join(model, join).flat_map do |join_model, config|
280
+ local_field = storage_field_for_join(model, config.fetch(:from))
281
+ foreign_field = storage_field_for_join(join_model, config.fetch(:to))
282
+ relation = config[:relation]
283
+ limit = config.key?(:limit) ? config[:limit] : nil
284
+ effective_limit = limit.nil? ? default_find_many_limit : limit.to_i
285
+ unique = relation == "one-to-one" || config[:unique]
286
+ should_limit = !unique && effective_limit.positive?
287
+
288
+ lookup = if should_limit
289
+ {
290
+ "$lookup" => {
291
+ "from" => collection_name(join_model),
292
+ "let" => {"localFieldValue" => "$#{local_field}"},
293
+ "pipeline" => [
294
+ {"$match" => {"$expr" => {"$eq" => ["$#{foreign_field}", "$$localFieldValue"]}}},
295
+ {"$limit" => effective_limit}
296
+ ],
297
+ "as" => join_model
298
+ }
299
+ }
300
+ else
301
+ {
302
+ "$lookup" => {
303
+ "from" => collection_name(join_model),
304
+ "localField" => local_field,
305
+ "foreignField" => foreign_field,
306
+ "as" => join_model
307
+ }
308
+ }
309
+ end
310
+
311
+ unique ? [lookup, {"$unwind" => {"path" => "$#{join_model}", "preserveNullAndEmptyArrays" => true}}] : [lookup]
312
+ end
313
+ end
314
+
315
+ def normalized_join(model, join)
316
+ join.each_with_object({}) do |(join_model, config), result|
317
+ join_model = join_model.to_s
318
+ result[join_model] = normalize_join_config(model, join_model, config)
319
+ end
320
+ end
321
+
322
+ def normalize_join_config(model, join_model, config)
323
+ if config.is_a?(Hash) && (config.key?(:on) || config.key?("on"))
324
+ on = config[:on] || config["on"]
325
+ relation = config[:relation] || config["relation"]
326
+ limit = config[:limit] || config["limit"]
327
+ from = fetch_key(on, :from)
328
+ to = fetch_key(on, :to)
329
+ return {from: Schema.storage_key(from), to: Schema.storage_key(to), relation: relation, limit: limit, unique: unique_join_field?(join_model, to)}
330
+ end
331
+
332
+ inferred = inferred_join_config(model, join_model)
333
+ if config.is_a?(Hash)
334
+ limit = config[:limit] || config["limit"]
335
+ relation = config[:relation] || config["relation"]
336
+ inferred = inferred.merge(limit: limit) if limit
337
+ inferred = inferred.merge(relation: relation) if relation
338
+ end
339
+ inferred
340
+ end
341
+
342
+ def inferred_join_config(model, join_model)
343
+ base_model = default_model_name(model)
344
+ target_model = default_model_name(join_model)
345
+ foreign_keys = fields_for(target_model).select do |_field, attributes|
346
+ reference_model_matches?(attributes, base_model)
347
+ end
348
+ forward_join = true
349
+
350
+ if foreign_keys.empty?
351
+ foreign_keys = fields_for(base_model).select do |_field, attributes|
352
+ reference_model_matches?(attributes, target_model)
353
+ end
354
+ forward_join = false
355
+ end
356
+
357
+ if foreign_keys.empty?
358
+ raise Error, "No foreign key found for model #{join_model} and base model #{model} while performing join operation."
359
+ end
360
+ if foreign_keys.length > 1
361
+ raise Error, "Multiple foreign keys found for model #{join_model} and base model #{model} while performing join operation. Only one foreign key is supported."
362
+ end
363
+
364
+ foreign_key, attributes = foreign_keys.first
365
+ reference = attributes.fetch(:references)
366
+ if forward_join
367
+ unique = attributes[:unique] == true
368
+ {from: reference.fetch(:field).to_s, to: foreign_key, relation: unique ? "one-to-one" : "one-to-many", unique: unique}
369
+ else
370
+ {from: foreign_key, to: reference.fetch(:field).to_s, relation: "one-to-one", unique: true}
371
+ end
372
+ end
373
+
374
+ def reference_model_matches?(attributes, model)
375
+ reference = attributes[:references]
376
+ return false unless reference
377
+
378
+ default_model_name(reference[:model] || reference["model"]) == model
379
+ end
380
+
381
+ def unique_join_field?(model, field)
382
+ field = resolve_field(model, field)
383
+ field == "id" || fields_for(model).dig(field, :unique) == true
384
+ end
385
+
386
+ def storage_field_for_join(model, field)
387
+ field = resolve_field(model, field)
388
+ (field == "id") ? "_id" : storage_field(model, field)
389
+ end
390
+
391
+ def projection_for(model, select, join)
392
+ selected_fields = Array(select).map { |field| storage_field_for_join(model, field) }
393
+ Array(select).each_with_object({}) do |field, projection|
394
+ projection[storage_field_for_join(model, field)] = 1
395
+ end.tap do |projection|
396
+ projection["_id"] = 0 unless selected_fields.include?("_id")
397
+ normalized_join(model, join).each_key { |join_model| projection[join_model] = 1 } if join
398
+ end
399
+ end
400
+
401
+ def sort_field(model, sort_by)
402
+ field = resolve_field(model, fetch_key(sort_by, :field))
403
+ storage_field_for_join(model, field)
404
+ end
405
+
406
+ def sort_direction(sort_by)
407
+ (fetch_key(sort_by, :direction).to_s == "desc") ? -1 : 1
408
+ end
409
+
410
+ def collection_for(model)
411
+ database.collection(collection_name(model))
412
+ end
413
+
414
+ def collection_name(model)
415
+ model = default_model_name(model)
416
+ configured = configured_model_name(model)
417
+ return "#{configured}s" if configured && use_plural
418
+ return configured if configured
419
+ return schema_for(model).fetch(:model_name) if use_plural
420
+
421
+ model.to_s
422
+ end
423
+
424
+ def to_document(model, record)
425
+ fields_for(model).each_with_object({}) do |(field, attributes), document|
426
+ next unless record.key?(field)
427
+
428
+ key = (field == "id") ? "_id" : storage_field(model, field)
429
+ document[key] = store_value(field, record[field], attributes)
430
+ end
431
+ end
432
+
433
+ def from_document(model, document, join: nil)
434
+ fields = fields_for(model)
435
+ record = fields.each_with_object({}) do |(field, attributes), output|
436
+ key = (field == "id") ? "_id" : storage_field(model, field)
437
+ output[field] = output_value(field, fetch_document(document, key), attributes) if document_key?(document, key)
438
+ end
439
+
440
+ if join
441
+ normalized_join(model, join).each do |join_model, config|
442
+ next unless document_key?(document, join_model)
443
+
444
+ joined_value = fetch_document(document, join_model)
445
+ record[join_model] = if joined_value.is_a?(Array)
446
+ joined_value.map { |entry| from_document(join_model, stringify_document(entry)) }
447
+ elsif joined_value
448
+ from_document(join_model, stringify_document(joined_value))
449
+ elsif config[:relation] == "one-to-one"
450
+ nil
451
+ else
452
+ []
453
+ end
454
+ end
455
+ end
456
+
457
+ record
458
+ end
459
+
460
+ def stringify_document(document)
461
+ document.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
462
+ end
463
+
464
+ def unwrap_update_result(result)
465
+ return result unless result.is_a?(Hash)
466
+ return result if document_key?(result, "_id")
467
+
468
+ if result.key?("value") && (result.key?("ok") || result.key?("lastErrorObject"))
469
+ return result["value"]
470
+ end
471
+ if result.key?(:value) && (result.key?(:ok) || result.key?(:last_error_object))
472
+ return result[:value]
473
+ end
474
+
475
+ result
476
+ end
477
+
478
+ def store_value(field, value, attributes, strict_id: false)
479
+ return nil if value.nil?
480
+ return Array(value).map { |entry| store_value(field, entry, attributes, strict_id: strict_id) } if value.is_a?(Array)
481
+
482
+ if id_field?(field, attributes)
483
+ return value if custom_id_generator?
484
+ return bson_id(value, strict: strict_id)
485
+ end
486
+
487
+ input_value(value, attributes)
488
+ end
489
+
490
+ def output_value(field, value, attributes)
491
+ return nil if value.nil?
492
+ if id_field?(field, attributes)
493
+ return value.to_uuid if bson_uuid?(value)
494
+ return value.to_s if value.is_a?(BSON::ObjectId)
495
+ return value.map { |entry| output_value(field, entry, attributes) } if value.is_a?(Array)
496
+ return value
497
+ end
498
+
499
+ output_scalar_value(value, attributes)
500
+ end
501
+
502
+ def id_field?(field, attributes)
503
+ field.to_s == "id" || attributes.dig(:references, :field) == "id"
504
+ end
505
+
506
+ def bson_id(value, strict:)
507
+ if use_uuid_ids?
508
+ return value if bson_uuid?(value)
509
+ return BSON::Binary.from_uuid(value.to_s) if value.is_a?(String)
510
+ raise MongoAdapterError.new("INVALID_ID", "Invalid id value") if strict
511
+
512
+ return value
513
+ end
514
+
515
+ return value if value.is_a?(BSON::ObjectId)
516
+ return BSON::ObjectId.from_string(value.to_s) if value.is_a?(String)
517
+ raise MongoAdapterError.new("INVALID_ID", "Invalid id value") if strict
518
+
519
+ value
520
+ rescue BSON::Error::InvalidObjectId, ArgumentError
521
+ value
522
+ end
523
+
524
+ def bson_uuid?(value)
525
+ defined?(BSON::Binary) && value.is_a?(BSON::Binary) && value.respond_to?(:to_uuid) && value.type == :uuid
526
+ end
527
+
528
+ def generated_id
529
+ generator = options.advanced.dig(:database, :generate_id)
530
+ return generator.call if generator.respond_to?(:call)
531
+ return SecureRandom.uuid if use_uuid_ids?
532
+ return BSON::ObjectId.new.to_s if defined?(BSON::ObjectId)
533
+
534
+ SecureRandom.hex(12)
535
+ end
536
+
537
+ def use_uuid_ids?
538
+ options.advanced.dig(:database, :generate_id) == "uuid"
539
+ end
540
+
541
+ def custom_id_generator?
542
+ options.advanced.dig(:database, :generate_id).respond_to?(:call)
543
+ end
544
+
545
+ def resolve_default(default)
546
+ default.respond_to?(:call) ? default.call : default
547
+ end
548
+
549
+ def coerce_value(value, attributes)
550
+ return value if value.nil?
551
+ return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
552
+
553
+ value
554
+ end
555
+
556
+ def input_value(value, attributes)
557
+ value = coerce_value(value, attributes)
558
+ return JSON.generate(value) if attributes[:type] == "json" && (value.is_a?(Hash) || value.is_a?(Array))
559
+
560
+ value
561
+ end
562
+
563
+ def output_scalar_value(value, attributes)
564
+ return JSON.parse(value) if attributes[:type] == "json" && value.is_a?(String)
565
+
566
+ coerce_value(value, attributes)
567
+ rescue JSON::ParserError
568
+ value
569
+ end
570
+
571
+ def coerce_where_value(value, attributes)
572
+ return value.map { |entry| coerce_where_value(entry, attributes) } if value.is_a?(Array)
573
+ return value == "true" if attributes[:type] == "boolean" && value.is_a?(String)
574
+ if attributes[:type] == "number" && value.is_a?(String) && !value.strip.empty?
575
+ parsed = Float(value)
576
+ return parsed.to_i if parsed.to_i == parsed
577
+
578
+ return parsed
579
+ end
580
+ return JSON.generate(value) if attributes[:type] == "json" && (value.is_a?(Hash) || value.is_a?(Array))
581
+
582
+ value
583
+ rescue ArgumentError
584
+ value
585
+ end
586
+
587
+ def session_options
588
+ @session ? {session: @session} : {}
589
+ end
590
+
591
+ def document_key?(document, key)
592
+ document.key?(key) || document.key?(key.to_sym)
593
+ end
594
+
595
+ def fetch_document(document, key)
596
+ return document[key] if document.key?(key)
597
+
598
+ document[key.to_sym]
599
+ end
600
+
601
+ def stringify_keys(data)
602
+ data.each_with_object({}) do |(key, value), result|
603
+ result[Schema.storage_key(key)] = value
604
+ end
605
+ end
606
+
607
+ def fetch_key(hash, key)
608
+ [key, key.to_s, Schema.storage_key(key), Schema.storage_key(key).to_sym].each do |candidate|
609
+ return hash[candidate] if hash.key?(candidate)
610
+ end
611
+ nil
612
+ end
613
+
614
+ def schema_for(model)
615
+ Schema.auth_tables(options).fetch(default_model_name(model))
616
+ end
617
+
618
+ def fields_for(model)
619
+ schema_for(model).fetch(:fields).merge("id" => {type: "string", required: true})
620
+ end
621
+
622
+ def default_model_name(model)
623
+ model = model.to_s
624
+ tables = Schema.auth_tables(options)
625
+ return model if tables.key?(model)
626
+
627
+ pluraless = model.end_with?("s") ? model[0...-1] : nil
628
+ return pluraless if pluraless && tables.key?(pluraless)
629
+
630
+ matched = tables.find { |_key, table| table[:model_name].to_s == model }
631
+ return matched.first if matched
632
+
633
+ raise Error, "Model \"#{model}\" not found in schema"
634
+ end
635
+
636
+ def configured_model_name(model)
637
+ configured = configured_model_option(model, :model_name)
638
+ return configured.to_s if configured
639
+
640
+ return nil if core_model?(model)
641
+
642
+ table_model_name = schema_for(model).fetch(:model_name).to_s
643
+ (table_model_name == physical_name(model)) ? nil : table_model_name
644
+ end
645
+
646
+ def configured_model_option(model, key)
647
+ data = options.respond_to?(model.to_sym) ? options.public_send(model.to_sym) : nil
648
+ data[key] || data[key.to_s] if data.respond_to?(:[])
649
+ end
650
+
651
+ def core_model?(model)
652
+ ["user", "session", "account", "verification", "rateLimit"].include?(model.to_s)
653
+ end
654
+
655
+ def resolve_field(model, field)
656
+ field = Schema.storage_key(field)
657
+ return "id" if field == "id" || field == "_id"
658
+
659
+ fields = fields_for(model)
660
+ return field if fields.key?(field)
661
+
662
+ matched = fields.find { |_key, attributes| attributes[:field_name].to_s == field.to_s }
663
+ return matched.first if matched
664
+
665
+ raise Error, "Field #{field} not found in model #{model}"
666
+ end
667
+
668
+ def storage_field(model, field)
669
+ fields_for(model).fetch(field.to_s).fetch(:field_name, physical_name(field))
670
+ end
671
+
672
+ def physical_name(value)
673
+ value.to_s
674
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
675
+ .tr("-", "_")
676
+ .downcase
677
+ end
678
+ end
679
+ end
680
+
681
+ MongoAdapter = MongoDB unless const_defined?(:MongoAdapter, false)
682
+ end
metadata ADDED
@@ -0,0 +1,189 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: better_auth-mongodb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.8.0
5
+ platform: ruby
6
+ authors:
7
+ - Sebastian Sala
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: better_auth
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: bigdecimal
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '3.1'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '5.0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '3.1'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '5.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: logger
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '1.6'
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '2.0'
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '1.6'
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '2.0'
66
+ - !ruby/object:Gem::Dependency
67
+ name: mongo
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '2.21'
73
+ type: :runtime
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '2.21'
80
+ - !ruby/object:Gem::Dependency
81
+ name: bundler
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '2.5'
87
+ type: :development
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '2.5'
94
+ - !ruby/object:Gem::Dependency
95
+ name: minitest
96
+ requirement: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '5.25'
101
+ type: :development
102
+ prerelease: false
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '5.25'
108
+ - !ruby/object:Gem::Dependency
109
+ name: rake
110
+ requirement: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '13.2'
115
+ type: :development
116
+ prerelease: false
117
+ version_requirements: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - "~>"
120
+ - !ruby/object:Gem::Version
121
+ version: '13.2'
122
+ - !ruby/object:Gem::Dependency
123
+ name: standardrb
124
+ requirement: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - "~>"
127
+ - !ruby/object:Gem::Version
128
+ version: '1.0'
129
+ type: :development
130
+ prerelease: false
131
+ version_requirements: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - "~>"
134
+ - !ruby/object:Gem::Version
135
+ version: '1.0'
136
+ - !ruby/object:Gem::Dependency
137
+ name: simplecov
138
+ requirement: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - "~>"
141
+ - !ruby/object:Gem::Version
142
+ version: '0.22'
143
+ type: :development
144
+ prerelease: false
145
+ version_requirements: !ruby/object:Gem::Requirement
146
+ requirements:
147
+ - - "~>"
148
+ - !ruby/object:Gem::Version
149
+ version: '0.22'
150
+ description: Adds a MongoDB database adapter for Better Auth Ruby. Better Auth Ruby
151
+ is an independent modern authentication framework for Ruby inspired by Better Auth.
152
+ Keeps MongoDB dependencies out of the core gem.
153
+ email:
154
+ - sebastian.sala.tech@gmail.com
155
+ executables: []
156
+ extensions: []
157
+ extra_rdoc_files: []
158
+ files:
159
+ - CHANGELOG.md
160
+ - README.md
161
+ - lib/better_auth/mongo_adapter.rb
162
+ - lib/better_auth/mongodb.rb
163
+ - lib/better_auth/mongodb/version.rb
164
+ homepage: https://github.com/sebasxsala/better-auth
165
+ licenses:
166
+ - MIT
167
+ metadata:
168
+ homepage_uri: https://github.com/sebasxsala/better-auth
169
+ source_code_uri: https://github.com/sebasxsala/better-auth
170
+ changelog_uri: https://github.com/sebasxsala/better-auth/blob/main/packages/better_auth-mongodb/CHANGELOG.md
171
+ bug_tracker_uri: https://github.com/sebasxsala/better-auth/issues
172
+ rdoc_options: []
173
+ require_paths:
174
+ - lib
175
+ required_ruby_version: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: 3.2.0
180
+ required_rubygems_version: !ruby/object:Gem::Requirement
181
+ requirements:
182
+ - - ">="
183
+ - !ruby/object:Gem::Version
184
+ version: '0'
185
+ requirements: []
186
+ rubygems_version: 3.6.9
187
+ specification_version: 4
188
+ summary: MongoDB adapter package for Better Auth Ruby
189
+ test_files: []