nobrainer-references 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 65580cce199dbbd155e48df8d0a7bbd3639829306b9b5ff6928030210cd37720
4
+ data.tar.gz: f1d9e1bde0a355826be32ad48dcccce95cdc236439f71d034b15e49822a7489c
5
+ SHA512:
6
+ metadata.gz: f38850e024055580bbad17616b9348e5c473ab4198b5f4e0aaacab94b2bb6a978fa74705067d81fb2546b70ebdb78c8747fea39f66376786d90a3ebff6b3bb56
7
+ data.tar.gz: 97bd8654f56171de5ad971b2fc302814efe68fcd5822f4c049b0594504f2eb362f99ed10f727abc2fef89e79ba3aa3eaa1d0cb248deac6533c0cbae2ec779731
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017-2024 Steve Sloan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # NoBrainer References
2
+
3
+ An alternative to ActiveRecord-style associations using idiomatic Ruby.
4
+
5
+ ## Design
6
+
7
+ ActiveRecord provides _associations_ between models as a convenient interface to SQL's use of _relations_ between table rows, and NoBrainer matches this functionality. But they are fundamentally different to the way that Ruby and most programming languages store relationships between models: _references_.
8
+
9
+ ## Installation
10
+
11
+ Add the Ruby gem to your Gemfile:
12
+
13
+ $ bundle add nobrainer-references
14
+
15
+ ## Example
16
+
17
+ class Publisher
18
+ include NoBrainer::Document
19
+ field :name, type: String
20
+ end
21
+
22
+ class Person
23
+ include NoBrainer::Document
24
+ field :name, type: String
25
+ end
26
+
27
+ class Book
28
+ include NoBrainer::Document
29
+ field :title, type: String
30
+ references_many :authors, model: Person
31
+ references_one :publisher
32
+ end
33
+
34
+ douglas_adams = Person.create!(name: "Douglas Adams")
35
+ john_lloyd = Person.create!(name: "John Lloyd")
36
+ publisher = Publisher.create!(name: "Pan Books")
37
+ book = Book.create!(
38
+ title: "The Meaning of Liff",
39
+ authors: [douglas_adams, john_lloyd],
40
+ publisher: publisher
41
+ )
42
+
43
+ ...
44
+
45
+ book = Book.where(title: "The Meaning of Liff").first
46
+ book.publisher.name #=> "Pan Books"
47
+ book.authors.map(&:name) #=> [ "Douglas Adams", "John Lloyd" ]
48
+
49
+ ## How It Works
50
+
51
+ This gem adds a NoBrainer field type that's a `Reference` to a model of a particular type. This type acts as a lazy-loading _delegator_, which serializes the referred object by its `id` when saving the model, and then later loads that object when it's dereferenced (or is eager-loaded).
52
+
53
+ `references_one` and `references_many` are convenience methods for creating fields with the correct types and default names according to convention:
54
+
55
+ references_one :publisher
56
+ # ... same as ...
57
+ field :publisher, type: Reference.to(Publisher), store_as: 'publishder_id'
58
+
59
+ references_many :authors, model: Person
60
+ # ... same as ...
61
+ field :authors, type: Array.of(Reference.to(Person)), store_as: 'author_ids'
62
+
63
+ It also supports eager-loading of references:
64
+
65
+ book = Book.eager_load(:authors, :publisher).where(title: "The Meaning of Liff").first
66
+ book.authors(&:map) #=> [ "Douglas Adams", "John Lloyd" ]
67
+
68
+ ## Future Plans
69
+
70
+ * Add a `referenced_by` convenience method for tracking which other models/fields reference this one. Provides parity with the ActiveRecord `belongs_to`/`has_many` inverse associations. This would be installed automatically by the `references_one` and `references_many` convenience methods.
71
+
72
+ * Use reference tracking to implement garbage collection.
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/dependencies/autoload'
4
+ require 'active_support/concern'
5
+
6
+ require 'no_brainer/types/reference'
7
+ require 'no_brainer/references/eager_loader'
8
+
9
+ module NoBrainer
10
+ module Document
11
+ module References
12
+ extend ActiveSupport::Concern
13
+
14
+ module ClassMethods
15
+ def references_one(name, model: nil, store_as: nil, inverse: nil, **opts)
16
+ name = name.to_s
17
+ store_as ||= "#{name}_id"
18
+ model ||= name.classify.constantize
19
+ if model.is_a?(String)
20
+ name = model
21
+ model = ->{ const_get(name) }
22
+ end
23
+
24
+ field name.to_sym, type: Reference.to(model), store_as: store_as, **opts
25
+ end
26
+
27
+ def references_many(name, model: nil, store_as: nil, inverse: nil, **opts)
28
+ name = name.to_s
29
+ store_as ||= "#{name.singularize}_ids"
30
+ model ||= name.singularize.classify.constantize
31
+ if model.is_a?(String)
32
+ name = model
33
+ model = ->{ const_get(name) }
34
+ end
35
+
36
+ field name.to_sym, type: Array.of(Reference.to(model)), store_as: store_as, **opts
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'no_brainer/document/association/eager_loader'
4
+
5
+ module NoBrainer::Document::References
6
+ # adapted from NoBrainer::Document::Association::EagerLoader
7
+ def self.eager_load(docs, field_name, field=nil, criteria=nil)
8
+ return if docs.blank?
9
+
10
+ field_name = field_name.to_sym
11
+ field ||= docs.first.root_class.fields[field_name]
12
+ ref_type = field_type = field[:type]
13
+ if (field_type <= Array) && field_type.respond_to?(:object_type) && (field_type.object_type <= NoBrainer::Reference)
14
+ ref_type = field_type.object_type
15
+ end
16
+ raise TypeError, "#{ref_type} is not a NoBrainer::Reference" unless ref_type <= NoBrainer::Reference
17
+ model_type = ref_type.model_type
18
+
19
+ refs = docs.flat_map { |doc| doc.read_attribute(field_name) }
20
+ refs.compact!
21
+ refs.reject!(&:__hasobj__)
22
+
23
+ if refs.present?
24
+ target_key = model_type.table_config.primary_key.to_sym
25
+ ref_ids = refs.map(&:id).uniq
26
+
27
+ query = model_type.without_ordering
28
+ query = query.merge(criteria) if criteria
29
+ targets = query.where(target_key.in => ref_ids).group_by(&target_key)
30
+ refs.each do |ref|
31
+ if (target = targets[ref.id]&.first)
32
+ ref.__setobj__(target)
33
+ end
34
+ end
35
+ refs.uniq!
36
+ end
37
+
38
+ docs.select { |doc| Array(doc.read_attribute(field_name)).all?(&:__hasobj__) }
39
+ end
40
+
41
+ module AssociationExt
42
+ def eager_load_association(docs, association_name, criteria=nil)
43
+ if (field = docs&.first) && (field = field.root_class.fields[association_name.to_sym]) && field_is_reference_type?(field)
44
+ NoBrainer::Document::References.eager_load(docs, association_name, field, criteria)
45
+ else
46
+ super
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def field_is_reference_type?(field)
53
+ field && (type = field[:type]) && (
54
+ (type <= NoBrainer::Reference) ||
55
+ (type <= Array && type.respond_to?(:object_type) && (type.object_type <= NoBrainer::Reference))
56
+ )
57
+ end
58
+
59
+ NoBrainer::Document::Association::EagerLoader.extend(self)
60
+ end
61
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NoBrainer
4
+ module References
5
+ VERSION = '1.0.0'.freeze
6
+ end
7
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'no_brainer/document'
4
+
5
+ module NoBrainer
6
+ class Reference < SimpleDelegator
7
+ mattr_accessor :autosave
8
+ self.autosave = true
9
+
10
+ def self.to(model_type = nil, &model_type_proc)
11
+ raise ArgumentError, "wrong number of arguments (given 2, expect 1)" if model_type && model_type_proc
12
+ raise ArgumentError, "wrong number of arguments (given 0, expect 1)" unless model_type || model_type_proc
13
+ if model_type.respond_to?(:call)
14
+ model_type_proc = model_type
15
+ model_type = nil
16
+ end
17
+
18
+ if model_type.const_defined?('Reference', !:inherited)
19
+ return model_type.const_get('Reference', !:inherited)
20
+ end
21
+
22
+ ref_type = if model_type
23
+ model_type = resolve_model_type(model_type)
24
+ ::Class.new(Reference) { define_singleton_method(:model_type) { model_type } }
25
+ else
26
+ # lazy-load model class
27
+ ::Class.new(Reference) { define_singleton_method(:model_type) { @model_type ||= resolve_model_type(model_type_proc) } }
28
+ end
29
+ model_type.const_set('Reference', ref_type)
30
+ end
31
+
32
+ def self.resolve_model_type(type)
33
+ type = type.call if type.respond_to?(:call)
34
+ if type.const_defined?('Reference', !:inherited)
35
+ return type.const_get('Reference', !:inherited)
36
+ end
37
+ unless type < Document
38
+ raise TypeError, "Expected Document subclass, got #{type.inspect}"
39
+ end
40
+ type
41
+ end
42
+ private_class_method :resolve_model_type
43
+
44
+ def self.name
45
+ str = "Reference"
46
+ str += "(#{model_type.name})" if respond_to?(:model_type)
47
+ str
48
+ end
49
+
50
+ attr_reader :id
51
+
52
+ def initialize(id, object = nil)
53
+ @id = id
54
+ __setobj__(object) unless object.nil?
55
+ end
56
+
57
+ def inspect
58
+ "#<*#{self.class.model_type} " + (
59
+ __hasobj__ ? __getobj__.inspectable_attributes.map { |k,v| "#{k}: #{v.inspect}" }.join(', ')
60
+ : "#{self.class.model_type.table_config.primary_key}: #{id.inspect}"
61
+ ) + ">"
62
+ end
63
+
64
+ def dup
65
+ self.class.new(@id)
66
+ end
67
+ alias_method :deep_dup, :dup
68
+
69
+ def __getobj__
70
+ super do
71
+ if @missing
72
+ nil
73
+ elsif @id
74
+ model = self.class.model_type
75
+ unless (obj = model.find?(@id))
76
+ @missing = true
77
+ raise NoBrainer::Error::MissingAttribute, "#{model} :#{model.pk_name}=>#{@id.inspect} not found"
78
+ end
79
+ __setobj__(obj)
80
+ elsif block_given?
81
+ yield
82
+ end
83
+ end
84
+ end
85
+
86
+ def __hasobj__
87
+ defined? @delegate_sd_obj
88
+ end
89
+
90
+ def self.nobrainer_cast_user_to_model(value)
91
+ case value
92
+ when Reference
93
+ unless value.class.model_type == model_type
94
+ raise NoBrainer::Error::InvalidType, "Expected Reference to #{model_type}, got #{value}"
95
+ end
96
+ value
97
+ when model_type
98
+ new(value.id, value)
99
+ when nil
100
+ nil
101
+ else
102
+ raise NoBrainer::Error::InvalidType
103
+ end
104
+ end
105
+
106
+ def self.nobrainer_cast_model_to_db(value)
107
+ value&.save! if value&.new_record? && self.autosave
108
+ value&.id
109
+ end
110
+
111
+ def self.nobrainer_cast_db_to_model(value)
112
+ value && new(value)
113
+ end
114
+ end
115
+
116
+ Document::Types::Reference = Reference
117
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/dependencies/autoload'
4
+ require 'nobrainer'
5
+ require 'no_brainer/document/references'
6
+
7
+ module NoBrainer
8
+ autoload :Array, :Reference
9
+
10
+ module Document
11
+ include References
12
+ end
13
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe NoBrainer::Document::References do
6
+ before(:all) do
7
+ class Person
8
+ include NoBrainer::Document
9
+ field :name, type: String
10
+ end
11
+
12
+ class Post
13
+ include NoBrainer::Document
14
+ field :title, type: String
15
+ field :authors, type: Array.of(Reference.to(Person))
16
+ field :publisher, type: Reference.to(Person)
17
+ end
18
+ end
19
+
20
+ it "eager loads single reference" do
21
+ publisher = Person.create!(name: Faker::Name.name)
22
+ post = Post.create!(title: Faker::Name.name, publisher: publisher)
23
+
24
+ post = Post.where(title: post.title).eager_load(:publisher).first
25
+ expect(Person).not_to receive(:find)
26
+ expect(post.publisher.name).to eq publisher.name
27
+ end
28
+
29
+ it "eager loads reference array" do
30
+ bob, doug = Person.create!(name: "Bob"), Person.create!(name: "Doug")
31
+ post = Post.create!(title: Faker::Name.name, authors: [bob, doug])
32
+
33
+ post = Post.where(title: post.title).eager_load(:authors).first
34
+ expect(Person).not_to receive(:find)
35
+ expect(post.authors.map(&:name)).to eq [bob, doug].map(&:name)
36
+ end
37
+ end
@@ -0,0 +1,57 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe NoBrainer::Reference do
4
+ before(:all) do
5
+ class Person
6
+ include NoBrainer::Document
7
+ field :name, type: String
8
+ end
9
+
10
+ class Post
11
+ include NoBrainer::Document
12
+ field :title, type: String
13
+ field :authors, type: Array.of(Reference.to(Person))
14
+ field :publisher, type: Reference.to(Person)
15
+ index :authors, multi: true
16
+ index :publisher
17
+ end
18
+
19
+ NoBrainer.sync_schema
20
+ end
21
+
22
+ it "references one" do
23
+ publisher = Person.create!(name: "Marvin")
24
+ post = Post.create!(title: "Stuff", publisher: publisher)
25
+
26
+ post.reload
27
+ expect(post.publisher.name).to eq publisher.name
28
+ expect(Post.where(publisher: publisher)).to eq [post]
29
+ end
30
+
31
+ it "references many" do
32
+ bob, doug = Person.create!(name: "Bob"), Person.create!(name: "Doug")
33
+ bob_post = Post.create!(title: "Bob Stuff", authors: bob).reload
34
+ doug_post = Post.create!(title: "Doug Stuff", authors: doug).reload
35
+ bd_post = Post.create!(title: "Body & Doug Stuff", authors: [bob, doug]).reload
36
+ db_post = Post.create!(title: "Doug & Bob Stuff", authors: [doug, bob]).reload
37
+
38
+ expect(Post.where(:authors.any => bob)).to eq [bob_post, bd_post, db_post]
39
+ expect(Post.where(:authors.any => doug)).to eq [doug_post, bd_post, db_post]
40
+ expect(Post.where(:authors.any.in => [bob, doug])).to eq [bob_post, doug_post, bd_post, db_post]
41
+ end
42
+
43
+ it "exception when referenced item is missing" do
44
+ publisher = Person.create!(name: "Marvin")
45
+ post = Post.create!(title: "Stuff", publisher: publisher)
46
+
47
+ expect(post.publisher).to eq publisher
48
+ publisher.delete
49
+ post.reload
50
+ expect {
51
+ post.publisher
52
+ }.to raise_error(NoBrainer::Error::MissingAttribute)
53
+
54
+ post.publisher = nil
55
+ post.save!
56
+ end
57
+ end
@@ -0,0 +1,23 @@
1
+ require 'rubygems'
2
+ require 'bundler'
3
+ Bundler.require(:default, :test)
4
+
5
+ SPEC_ROOT = File.expand_path File.dirname(__FILE__)
6
+ Dir["#{SPEC_ROOT}/support/**/*.rb"].each { |f| require f unless File.basename(f) =~ /^_/ }
7
+
8
+ RSpec.configure do |config|
9
+ config.order = :random
10
+ config.color = true
11
+ config.expect_with :rspec do |c|
12
+ c.syntax = :expect
13
+ end
14
+
15
+ if ENV['TRACE']
16
+ config.before do
17
+ $trace_file = File.open(ENV['TRACE'], 'w')
18
+ TracePoint.new(:call, :raise) do |tp|
19
+ $trace_file.puts "#{tp.event} #{tp.path} #{tp.method_id}:#{tp.lineno}"
20
+ end.enable
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,12 @@
1
+ RSpec.configure do |config|
2
+ config.around(:example, remove_const: true) do |example|
3
+ const_before = Object.constants
4
+
5
+ example.run
6
+
7
+ const_after = Object.constants
8
+ (const_after - const_before).each do |const|
9
+ Object.send(:remove_const, const)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,8 @@
1
+ require 'factory_bot'
2
+
3
+ RSpec.configure do |config|
4
+ config.include FactoryBot::Syntax::Methods
5
+ config.before(:suite) do
6
+ FactoryBot.find_definitions
7
+ end
8
+ end
@@ -0,0 +1,15 @@
1
+ RSpec.configure do |config|
2
+ config.before(:suite) do
3
+ NoBrainer.sync_schema
4
+ NoBrainer.purge!
5
+ end
6
+
7
+ config.after(:suite) do
8
+ NoBrainer.drop!
9
+ end
10
+ end
11
+
12
+ NoBrainer.configure do |config|
13
+ config.app_name = 'nobrainer_refs'
14
+ config.environment = :test
15
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nobrainer-references
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Steve Sloan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-12-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nobrainer
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.44'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.44'
27
+ description: ''
28
+ email: steve@finagle.org
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - LICENSE
34
+ - README.md
35
+ - lib/no_brainer/document/references.rb
36
+ - lib/no_brainer/references/eager_loader.rb
37
+ - lib/no_brainer/references/version.rb
38
+ - lib/no_brainer/types/reference.rb
39
+ - lib/nobrainer-references.rb
40
+ - spec/integration/eager_loader_spec.rb
41
+ - spec/integration/reference_spec.rb
42
+ - spec/spec_helper.rb
43
+ - spec/support/auto_remove_const_in_rspec.rb
44
+ - spec/support/factory_bot.rb
45
+ - spec/support/nobrainer.rb
46
+ homepage: https://github.com/CodeMonkeySteve/nobrainer-references
47
+ licenses:
48
+ - MIT
49
+ metadata: {}
50
+ post_install_message:
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: 2.7.0
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 3.5.22
66
+ signing_key:
67
+ specification_version: 3
68
+ summary: NoBrainer support for model references
69
+ test_files:
70
+ - spec/integration/eager_loader_spec.rb
71
+ - spec/integration/reference_spec.rb
72
+ - spec/spec_helper.rb
73
+ - spec/support/auto_remove_const_in_rspec.rb
74
+ - spec/support/factory_bot.rb
75
+ - spec/support/nobrainer.rb