better_auth-mongo-adapter 0.1.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: 7f83712d43953a0cbf57ae29664ab1f4592f81ad4134347a9f8096aaac635902
4
+ data.tar.gz: eb3d6f9d93723b80abe9b8488956d75112578f8b2f6fe5e2f95a887f12b36ddf
5
+ SHA512:
6
+ metadata.gz: 519e64619b3c068816d2d3a6243368fee1abfb373cdb313af22524254f8c3c8ece2945afca37c893a6487e80d61c5a25153e03737019fde0375a5d80c26ae505
7
+ data.tar.gz: d7ecfde5895aec65f6164309b5e9739befa2f5ed7b1cd1709c9669f43af603ac785793113bdff0eda14fb7b52d37eabaac8adefb78fdbbed958c34006cf96723
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Kept at `0.1.0` for now so this first-publish package can be released separately when ready.
6
+
7
+ ## 0.1.0
8
+
9
+ - Extract MongoDB adapter support into the `better_auth-mongo-adapter` package.
10
+ - 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,33 @@
1
+ # better_auth-mongo-adapter
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-mongo-adapter"
11
+ ```
12
+
13
+ ```ruby
14
+ require "mongo"
15
+ require "better_auth/mongo_adapter"
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: BetterAuth::Adapters::MongoDB.new(
22
+ database: mongo_client.database,
23
+ client: mongo_client,
24
+ transaction: false
25
+ )
26
+ )
27
+ ```
28
+
29
+ ## Notes
30
+
31
+ 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.
32
+
33
+ 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.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module MongoAdapter
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,634 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth"
4
+ require "mongo"
5
+ require "securerandom"
6
+ require "time"
7
+
8
+ module BetterAuth
9
+ module Adapters
10
+ class MongoDB < Base
11
+ class MongoAdapterError < Error
12
+ attr_reader :code
13
+
14
+ def initialize(code, message)
15
+ @code = code
16
+ super(message)
17
+ end
18
+ end
19
+
20
+ attr_reader :database, :client, :use_plural
21
+
22
+ def initialize(options = nil, database:, client: nil, transaction: nil, use_plural: false, session: nil)
23
+ require "mongo" unless database
24
+
25
+ super(options || Configuration.new(secret: Configuration::DEFAULT_SECRET, database: :memory))
26
+ @database = database
27
+ @client = client
28
+ @transaction_enabled = transaction.nil? ? !client.nil? : !!transaction
29
+ @use_plural = !!use_plural
30
+ @session = session
31
+ end
32
+
33
+ def create(model:, data:, force_allow_id: false)
34
+ model = model.to_s
35
+ record = transform_input(model, data, "create", force_allow_id)
36
+ document = to_document(model, record)
37
+ collection_for(model).insert_one(document, session_options)
38
+ from_document(model, document)
39
+ end
40
+
41
+ def find_one(model:, where: [], select: nil, join: nil)
42
+ find_many(model: model, where: where, select: select, join: join, limit: 1).first
43
+ end
44
+
45
+ def find_many(model:, where: [], sort_by: nil, limit: nil, offset: nil, select: nil, join: nil)
46
+ model = model.to_s
47
+ pipeline = [{"$match" => mongo_filter(model, where || [])}]
48
+ pipeline.concat(join_stages(model, join)) if join
49
+ pipeline << {"$project" => projection_for(model, select, join)} if select && !select.empty?
50
+ pipeline << {"$sort" => {sort_field(model, sort_by) => sort_direction(sort_by)}} if sort_by
51
+ pipeline << {"$skip" => offset.to_i} if offset
52
+ pipeline << {"$limit" => limit.to_i} if limit
53
+
54
+ collection_for(model)
55
+ .aggregate(pipeline, session_options)
56
+ .to_a
57
+ .map { |document| from_document(model, stringify_document(document), join: join) }
58
+ end
59
+
60
+ def update(model:, where:, update:)
61
+ model = model.to_s
62
+ data = transform_input(model, update, "update", true)
63
+ document = to_document(model, data)
64
+ document.delete("_id")
65
+ result = collection_for(model).find_one_and_update(
66
+ mongo_filter(model, where || []),
67
+ {"$set" => document},
68
+ session_options.merge(return_document: :after)
69
+ )
70
+ result = unwrap_update_result(result)
71
+ result ? from_document(model, stringify_document(result)) : nil
72
+ end
73
+
74
+ def update_many(model:, where:, update:)
75
+ model = model.to_s
76
+ data = transform_input(model, update, "update", true)
77
+ document = to_document(model, data)
78
+ document.delete("_id")
79
+ result = collection_for(model).update_many(
80
+ mongo_filter(model, where || []),
81
+ {"$set" => document},
82
+ session_options
83
+ )
84
+ result.respond_to?(:modified_count) ? result.modified_count : result.to_i
85
+ end
86
+
87
+ def delete(model:, where:)
88
+ collection_for(model.to_s).delete_one(mongo_filter(model.to_s, where || []), session_options)
89
+ nil
90
+ end
91
+
92
+ def delete_many(model:, where:)
93
+ result = collection_for(model.to_s).delete_many(mongo_filter(model.to_s, where || []), session_options)
94
+ result.respond_to?(:deleted_count) ? result.deleted_count : result.to_i
95
+ end
96
+
97
+ def count(model:, where: nil)
98
+ pipeline = [
99
+ {"$match" => mongo_filter(model.to_s, where || [])},
100
+ {"$count" => "total"}
101
+ ]
102
+ row = collection_for(model.to_s).aggregate(pipeline, session_options).to_a.first
103
+ return 0 unless row
104
+
105
+ (row["total"] || row[:total] || 0).to_i
106
+ end
107
+
108
+ def transaction
109
+ return yield self unless client && @transaction_enabled && client.respond_to?(:start_session)
110
+
111
+ session = client.start_session
112
+ begin
113
+ session.start_transaction
114
+ adapter = self.class.new(options, database: database, client: client, transaction: @transaction_enabled, use_plural: use_plural, session: session)
115
+ result = yield adapter
116
+ session.commit_transaction
117
+ result
118
+ rescue
119
+ session.abort_transaction
120
+ raise
121
+ ensure
122
+ session.end_session
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def transform_input(model, data, action, force_allow_id)
129
+ fields = fields_for(model)
130
+ input = stringify_keys(data)
131
+ output = {}
132
+
133
+ fields.each do |field, attributes|
134
+ next if field == "id" && input.key?(field) && !force_allow_id
135
+
136
+ value_provided = input.key?(field)
137
+ value = input[field]
138
+ if value_provided && attributes[:input] == false && value && !force_allow_id
139
+ raise APIError.new("BAD_REQUEST", message: "#{field} is not allowed to be set")
140
+ end
141
+
142
+ if !value_provided && action == "create" && attributes.key?(:default_value)
143
+ value = resolve_default(attributes[:default_value])
144
+ value_provided = true
145
+ elsif !value_provided && action == "update" && attributes[:on_update]
146
+ value = resolve_default(attributes[:on_update])
147
+ value_provided = true
148
+ end
149
+
150
+ if !value_provided && action == "create" && attributes[:required]
151
+ raise APIError.new("BAD_REQUEST", message: "#{field} is required") unless field == "id"
152
+ end
153
+ output[field] = coerce_value(value, attributes) if value_provided
154
+ end
155
+
156
+ output["id"] = generated_id if action == "create" && !output.key?("id")
157
+ output
158
+ end
159
+
160
+ def mongo_filter(model, where)
161
+ clauses = Array(where)
162
+ return {} if clauses.empty?
163
+
164
+ clauses.each_with_index.reduce(nil) do |filter, (clause, index)|
165
+ condition = condition_for(model, clause)
166
+ next condition if index.zero?
167
+
168
+ if fetch_key(clause, :connector).to_s.upcase == "OR"
169
+ {"$or" => [filter, condition]}
170
+ else
171
+ {"$and" => [filter, condition]}
172
+ end
173
+ end
174
+ end
175
+
176
+ def condition_for(model, clause)
177
+ operator = (fetch_key(clause, :operator) || "eq").to_s.downcase
178
+ value = fetch_key(clause, :value)
179
+ if operator == "in" && !value.is_a?(Array)
180
+ raise MongoAdapterError.new("UNSUPPORTED_OPERATOR", "Value must be an array")
181
+ end
182
+
183
+ field = resolve_field(model, fetch_key(clause, :field))
184
+ attributes = fields_for(model).fetch(field)
185
+ key = (field == "id") ? "_id" : storage_field(model, field)
186
+ mode = (fetch_key(clause, :mode) || "sensitive").to_s
187
+ id_field = id_field?(field, attributes)
188
+ insensitive = !id_field && mode == "insensitive" && insensitive_value?(value)
189
+ value = coerce_where_value(value, attributes)
190
+
191
+ case operator
192
+ when "eq"
193
+ (insensitive && value.is_a?(String)) ? regex_condition(key, value, :eq, insensitive: true) : {key => store_value(field, value, attributes, strict_id: true)}
194
+ when "in"
195
+ (insensitive && value.is_a?(Array)) ? insensitive_in_condition(key, value) : {key => {"$in" => Array(value).map { |entry| store_value(field, entry, attributes, strict_id: true) }}}
196
+ when "not_in"
197
+ (insensitive && value.is_a?(Array)) ? insensitive_not_in_condition(key, value) : {key => {"$nin" => Array(value).map { |entry| store_value(field, entry, attributes, strict_id: true) }}}
198
+ when "ne"
199
+ (insensitive && value.is_a?(String)) ? {key => {"$not" => regex_for(value, :eq, insensitive: true)}} : {key => {"$ne" => store_value(field, value, attributes, strict_id: true)}}
200
+ when "gt", "gte", "lt", "lte"
201
+ {key => {"$#{operator}" => store_value(field, value, attributes, strict_id: true)}}
202
+ when "contains", "starts_with", "ends_with"
203
+ regex_condition(key, value.to_s, operator.to_sym, insensitive: insensitive)
204
+ else
205
+ raise MongoAdapterError.new("UNSUPPORTED_OPERATOR", "Unsupported operator: #{operator}")
206
+ end
207
+ end
208
+
209
+ def insensitive_value?(value)
210
+ value.is_a?(String) || (value.is_a?(Array) && value.all? { |entry| entry.is_a?(String) })
211
+ end
212
+
213
+ def insensitive_in_condition(key, values)
214
+ return {"$expr" => {"$eq" => [1, 0]}} if values.empty?
215
+
216
+ {"$or" => values.map { |value| regex_condition(key, value, :eq, insensitive: true) }}
217
+ end
218
+
219
+ def insensitive_not_in_condition(key, values)
220
+ return {} if values.empty?
221
+
222
+ {"$nor" => values.map { |value| regex_condition(key, value, :eq, insensitive: true) }}
223
+ end
224
+
225
+ def regex_condition(key, value, operator, insensitive:)
226
+ {key => regex_for(value, operator, insensitive: insensitive)}
227
+ end
228
+
229
+ def regex_for(value, operator, insensitive:)
230
+ escaped = Regexp.escape(value.to_s[0, 256])
231
+ pattern = case operator.to_s
232
+ when "eq" then "\\A#{escaped}\\z"
233
+ when "starts_with" then "\\A#{escaped}"
234
+ when "ends_with" then "#{escaped}\\z"
235
+ else escaped
236
+ end
237
+ Regexp.new(pattern, insensitive ? Regexp::IGNORECASE : nil)
238
+ end
239
+
240
+ def join_stages(model, join)
241
+ normalized_join(model, join).flat_map do |join_model, config|
242
+ local_field = storage_field_for_join(model, config.fetch(:from))
243
+ foreign_field = storage_field_for_join(join_model, config.fetch(:to))
244
+ relation = config[:relation]
245
+ limit = config[:limit]
246
+ unique = relation == "one-to-one" || config[:unique]
247
+ should_limit = !unique && limit && limit.to_i.positive?
248
+
249
+ lookup = if should_limit
250
+ {
251
+ "$lookup" => {
252
+ "from" => collection_name(join_model),
253
+ "let" => {"localFieldValue" => "$#{local_field}"},
254
+ "pipeline" => [
255
+ {"$match" => {"$expr" => {"$eq" => ["$#{foreign_field}", "$$localFieldValue"]}}},
256
+ {"$limit" => limit.to_i}
257
+ ],
258
+ "as" => join_model
259
+ }
260
+ }
261
+ else
262
+ {
263
+ "$lookup" => {
264
+ "from" => collection_name(join_model),
265
+ "localField" => local_field,
266
+ "foreignField" => foreign_field,
267
+ "as" => join_model
268
+ }
269
+ }
270
+ end
271
+
272
+ unique ? [lookup, {"$unwind" => {"path" => "$#{join_model}", "preserveNullAndEmptyArrays" => true}}] : [lookup]
273
+ end
274
+ end
275
+
276
+ def normalized_join(model, join)
277
+ join.each_with_object({}) do |(join_model, config), result|
278
+ join_model = join_model.to_s
279
+ result[join_model] = normalize_join_config(model, join_model, config)
280
+ end
281
+ end
282
+
283
+ def normalize_join_config(model, join_model, config)
284
+ if config.is_a?(Hash) && (config.key?(:on) || config.key?("on"))
285
+ on = config[:on] || config["on"]
286
+ relation = config[:relation] || config["relation"]
287
+ limit = config[:limit] || config["limit"]
288
+ from = fetch_key(on, :from)
289
+ to = fetch_key(on, :to)
290
+ return {from: Schema.storage_key(from), to: Schema.storage_key(to), relation: relation, limit: limit, unique: unique_join_field?(join_model, to)}
291
+ end
292
+
293
+ inferred_join_config(model, join_model)
294
+ end
295
+
296
+ def inferred_join_config(model, join_model)
297
+ base_model = default_model_name(model)
298
+ target_model = default_model_name(join_model)
299
+ foreign_keys = fields_for(target_model).select do |_field, attributes|
300
+ reference_model_matches?(attributes, base_model)
301
+ end
302
+ forward_join = true
303
+
304
+ if foreign_keys.empty?
305
+ foreign_keys = fields_for(base_model).select do |_field, attributes|
306
+ reference_model_matches?(attributes, target_model)
307
+ end
308
+ forward_join = false
309
+ end
310
+
311
+ if foreign_keys.empty?
312
+ raise Error, "No foreign key found for model #{join_model} and base model #{model} while performing join operation."
313
+ end
314
+ if foreign_keys.length > 1
315
+ raise Error, "Multiple foreign keys found for model #{join_model} and base model #{model} while performing join operation. Only one foreign key is supported."
316
+ end
317
+
318
+ foreign_key, attributes = foreign_keys.first
319
+ reference = attributes.fetch(:references)
320
+ if forward_join
321
+ unique = attributes[:unique] == true
322
+ {from: reference.fetch(:field).to_s, to: foreign_key, relation: unique ? "one-to-one" : "one-to-many", unique: unique}
323
+ else
324
+ {from: foreign_key, to: reference.fetch(:field).to_s, relation: "one-to-one", unique: true}
325
+ end
326
+ end
327
+
328
+ def reference_model_matches?(attributes, model)
329
+ reference = attributes[:references]
330
+ return false unless reference
331
+
332
+ default_model_name(reference[:model] || reference["model"]) == model
333
+ end
334
+
335
+ def unique_join_field?(model, field)
336
+ field = resolve_field(model, field)
337
+ field == "id" || fields_for(model).dig(field, :unique) == true
338
+ end
339
+
340
+ def storage_field_for_join(model, field)
341
+ field = resolve_field(model, field)
342
+ (field == "id") ? "_id" : storage_field(model, field)
343
+ end
344
+
345
+ def projection_for(model, select, join)
346
+ selected_fields = Array(select).map { |field| storage_field_for_join(model, field) }
347
+ Array(select).each_with_object({}) do |field, projection|
348
+ projection[storage_field_for_join(model, field)] = 1
349
+ end.tap do |projection|
350
+ projection["_id"] = 0 unless selected_fields.include?("_id")
351
+ normalized_join(model, join).each_key { |join_model| projection[join_model] = 1 } if join
352
+ end
353
+ end
354
+
355
+ def sort_field(model, sort_by)
356
+ field = resolve_field(model, fetch_key(sort_by, :field))
357
+ storage_field_for_join(model, field)
358
+ end
359
+
360
+ def sort_direction(sort_by)
361
+ (fetch_key(sort_by, :direction).to_s == "desc") ? -1 : 1
362
+ end
363
+
364
+ def collection_for(model)
365
+ database.collection(collection_name(model))
366
+ end
367
+
368
+ def collection_name(model)
369
+ model = default_model_name(model)
370
+ configured = configured_model_name(model)
371
+ return "#{configured}s" if configured && use_plural
372
+ return configured if configured
373
+ return schema_for(model).fetch(:model_name) if use_plural
374
+
375
+ model.to_s
376
+ end
377
+
378
+ def to_document(model, record)
379
+ fields_for(model).each_with_object({}) do |(field, attributes), document|
380
+ next unless record.key?(field)
381
+
382
+ key = (field == "id") ? "_id" : storage_field(model, field)
383
+ document[key] = store_value(field, record[field], attributes)
384
+ end
385
+ end
386
+
387
+ def from_document(model, document, join: nil)
388
+ fields = fields_for(model)
389
+ record = fields.each_with_object({}) do |(field, attributes), output|
390
+ key = (field == "id") ? "_id" : storage_field(model, field)
391
+ output[field] = output_value(field, fetch_document(document, key), attributes) if document_key?(document, key)
392
+ end
393
+
394
+ if join
395
+ normalized_join(model, join).each do |join_model, config|
396
+ next unless document_key?(document, join_model)
397
+
398
+ joined_value = fetch_document(document, join_model)
399
+ record[join_model] = if joined_value.is_a?(Array)
400
+ joined_value.map { |entry| from_document(join_model, stringify_document(entry)) }
401
+ elsif joined_value
402
+ from_document(join_model, stringify_document(joined_value))
403
+ elsif config[:relation] == "one-to-one"
404
+ nil
405
+ else
406
+ []
407
+ end
408
+ end
409
+ end
410
+
411
+ record
412
+ end
413
+
414
+ def stringify_document(document)
415
+ document.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
416
+ end
417
+
418
+ def unwrap_update_result(result)
419
+ return result unless result.is_a?(Hash)
420
+ return result if document_key?(result, "_id")
421
+
422
+ if result.key?("value") && (result.key?("ok") || result.key?("lastErrorObject"))
423
+ return result["value"]
424
+ end
425
+ if result.key?(:value) && (result.key?(:ok) || result.key?(:last_error_object))
426
+ return result[:value]
427
+ end
428
+
429
+ result
430
+ end
431
+
432
+ def store_value(field, value, attributes, strict_id: false)
433
+ return nil if value.nil?
434
+ return Array(value).map { |entry| store_value(field, entry, attributes, strict_id: strict_id) } if value.is_a?(Array)
435
+
436
+ if id_field?(field, attributes)
437
+ return value if custom_id_generator?
438
+ return bson_id(value, strict: strict_id)
439
+ end
440
+
441
+ input_value(value, attributes)
442
+ end
443
+
444
+ def output_value(field, value, attributes)
445
+ return nil if value.nil?
446
+ if id_field?(field, attributes)
447
+ return value.to_uuid if bson_uuid?(value)
448
+ return value.to_s if value.is_a?(BSON::ObjectId)
449
+ return value.map { |entry| output_value(field, entry, attributes) } if value.is_a?(Array)
450
+ return value
451
+ end
452
+
453
+ output_scalar_value(value, attributes)
454
+ end
455
+
456
+ def id_field?(field, attributes)
457
+ field.to_s == "id" || attributes.dig(:references, :field) == "id"
458
+ end
459
+
460
+ def bson_id(value, strict:)
461
+ if use_uuid_ids?
462
+ return value if bson_uuid?(value)
463
+ return BSON::Binary.from_uuid(value.to_s) if value.is_a?(String)
464
+ raise MongoAdapterError.new("INVALID_ID", "Invalid id value") if strict
465
+
466
+ return value
467
+ end
468
+
469
+ return value if value.is_a?(BSON::ObjectId)
470
+ return BSON::ObjectId.from_string(value.to_s) if value.is_a?(String)
471
+ raise MongoAdapterError.new("INVALID_ID", "Invalid id value") if strict
472
+
473
+ value
474
+ rescue BSON::Error::InvalidObjectId, ArgumentError
475
+ value
476
+ end
477
+
478
+ def bson_uuid?(value)
479
+ defined?(BSON::Binary) && value.is_a?(BSON::Binary) && value.respond_to?(:to_uuid) && value.type == :uuid
480
+ end
481
+
482
+ def generated_id
483
+ generator = options.advanced.dig(:database, :generate_id)
484
+ return generator.call if generator.respond_to?(:call)
485
+ return SecureRandom.uuid if use_uuid_ids?
486
+ return BSON::ObjectId.new.to_s if defined?(BSON::ObjectId)
487
+
488
+ SecureRandom.hex(12)
489
+ end
490
+
491
+ def use_uuid_ids?
492
+ options.advanced.dig(:database, :generate_id) == "uuid"
493
+ end
494
+
495
+ def custom_id_generator?
496
+ options.advanced.dig(:database, :generate_id).respond_to?(:call)
497
+ end
498
+
499
+ def resolve_default(default)
500
+ default.respond_to?(:call) ? default.call : default
501
+ end
502
+
503
+ def coerce_value(value, attributes)
504
+ return value if value.nil?
505
+ return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
506
+
507
+ value
508
+ end
509
+
510
+ def input_value(value, attributes)
511
+ value = coerce_value(value, attributes)
512
+ return JSON.generate(value) if attributes[:type] == "json" && (value.is_a?(Hash) || value.is_a?(Array))
513
+
514
+ value
515
+ end
516
+
517
+ def output_scalar_value(value, attributes)
518
+ return JSON.parse(value) if attributes[:type] == "json" && value.is_a?(String)
519
+
520
+ coerce_value(value, attributes)
521
+ rescue JSON::ParserError
522
+ value
523
+ end
524
+
525
+ def coerce_where_value(value, attributes)
526
+ return value.map { |entry| coerce_where_value(entry, attributes) } if value.is_a?(Array)
527
+ return value == "true" if attributes[:type] == "boolean" && value.is_a?(String)
528
+ if attributes[:type] == "number" && value.is_a?(String) && !value.strip.empty?
529
+ parsed = Float(value)
530
+ return parsed.to_i if parsed.to_i == parsed
531
+
532
+ return parsed
533
+ end
534
+ return JSON.generate(value) if attributes[:type] == "json" && (value.is_a?(Hash) || value.is_a?(Array))
535
+
536
+ value
537
+ rescue ArgumentError
538
+ value
539
+ end
540
+
541
+ def session_options
542
+ @session ? {session: @session} : {}
543
+ end
544
+
545
+ def document_key?(document, key)
546
+ document.key?(key) || document.key?(key.to_sym)
547
+ end
548
+
549
+ def fetch_document(document, key)
550
+ return document[key] if document.key?(key)
551
+
552
+ document[key.to_sym]
553
+ end
554
+
555
+ def stringify_keys(data)
556
+ data.each_with_object({}) do |(key, value), result|
557
+ result[Schema.storage_key(key)] = value
558
+ end
559
+ end
560
+
561
+ def fetch_key(hash, key)
562
+ [key, key.to_s, Schema.storage_key(key), Schema.storage_key(key).to_sym].each do |candidate|
563
+ return hash[candidate] if hash.key?(candidate)
564
+ end
565
+ nil
566
+ end
567
+
568
+ def schema_for(model)
569
+ Schema.auth_tables(options).fetch(default_model_name(model))
570
+ end
571
+
572
+ def fields_for(model)
573
+ schema_for(model).fetch(:fields).merge("id" => {type: "string", required: true})
574
+ end
575
+
576
+ def default_model_name(model)
577
+ model = model.to_s
578
+ tables = Schema.auth_tables(options)
579
+ return model if tables.key?(model)
580
+
581
+ pluraless = model.end_with?("s") ? model[0...-1] : nil
582
+ return pluraless if pluraless && tables.key?(pluraless)
583
+
584
+ matched = tables.find { |_key, table| table[:model_name].to_s == model }
585
+ return matched.first if matched
586
+
587
+ raise Error, "Model \"#{model}\" not found in schema"
588
+ end
589
+
590
+ def configured_model_name(model)
591
+ configured = configured_model_option(model, :model_name)
592
+ return configured.to_s if configured
593
+
594
+ return nil if core_model?(model)
595
+
596
+ table_model_name = schema_for(model).fetch(:model_name).to_s
597
+ (table_model_name == physical_name(model)) ? nil : table_model_name
598
+ end
599
+
600
+ def configured_model_option(model, key)
601
+ data = options.respond_to?(model.to_sym) ? options.public_send(model.to_sym) : nil
602
+ data[key] || data[key.to_s] if data.respond_to?(:[])
603
+ end
604
+
605
+ def core_model?(model)
606
+ ["user", "session", "account", "verification", "rateLimit"].include?(model.to_s)
607
+ end
608
+
609
+ def resolve_field(model, field)
610
+ field = Schema.storage_key(field)
611
+ return "id" if field == "id" || field == "_id"
612
+
613
+ fields = fields_for(model)
614
+ return field if fields.key?(field)
615
+
616
+ matched = fields.find { |_key, attributes| attributes[:field_name].to_s == field.to_s }
617
+ return matched.first if matched
618
+
619
+ raise Error, "Field #{field} not found in model #{model}"
620
+ end
621
+
622
+ def storage_field(model, field)
623
+ fields_for(model).fetch(field.to_s).fetch(:field_name, physical_name(field))
624
+ end
625
+
626
+ def physical_name(value)
627
+ value.to_s
628
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
629
+ .tr("-", "_")
630
+ .downcase
631
+ end
632
+ end
633
+ end
634
+ end
metadata ADDED
@@ -0,0 +1,187 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: better_auth-mongo-adapter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.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 without requiring
151
+ MongoDB dependencies in the core gem.
152
+ email:
153
+ - sebastian.sala.tech@gmail.com
154
+ executables: []
155
+ extensions: []
156
+ extra_rdoc_files: []
157
+ files:
158
+ - CHANGELOG.md
159
+ - README.md
160
+ - lib/better_auth/mongo_adapter.rb
161
+ - lib/better_auth/mongo_adapter/version.rb
162
+ homepage: https://github.com/sebasxsala/better-auth
163
+ licenses:
164
+ - MIT
165
+ metadata:
166
+ homepage_uri: https://github.com/sebasxsala/better-auth
167
+ source_code_uri: https://github.com/sebasxsala/better-auth
168
+ changelog_uri: https://github.com/sebasxsala/better-auth/blob/main/packages/better_auth-mongo-adapter/CHANGELOG.md
169
+ bug_tracker_uri: https://github.com/sebasxsala/better-auth/issues
170
+ rdoc_options: []
171
+ require_paths:
172
+ - lib
173
+ required_ruby_version: !ruby/object:Gem::Requirement
174
+ requirements:
175
+ - - ">="
176
+ - !ruby/object:Gem::Version
177
+ version: 3.2.0
178
+ required_rubygems_version: !ruby/object:Gem::Requirement
179
+ requirements:
180
+ - - ">="
181
+ - !ruby/object:Gem::Version
182
+ version: '0'
183
+ requirements: []
184
+ rubygems_version: 3.6.9
185
+ specification_version: 4
186
+ summary: MongoDB adapter package for Better Auth Ruby
187
+ test_files: []