├── .github └── workflows │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── graphql-dataloader-activerecord.gemspec ├── lib ├── dataloader_relation_proxy.rb └── dataloader_relation_proxy │ ├── active_record_object.rb │ ├── collection.rb │ ├── lazy.rb │ ├── record.rb │ └── version.rb └── spec ├── dataloader_relation_proxy ├── lazy_spec.rb └── record_spec.rb ├── dataloader_relation_proxy_spec.rb ├── model_fixtures.rb ├── spec_helper.rb └── test_implementation └── test_implementation_schema.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '2.7.3' 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | - name: Run the default task 27 | run: bundle exec rake 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.7 3 | NewCops: enable 4 | SuggestExtensions: false 5 | Metrics/BlockLength: 6 | Exclude: 7 | - '*.gemspec' 8 | AllowedMethods: ['describe', 'context'] 9 | Layout/ParameterAlignment: 10 | EnforcedStyle: with_fixed_indentation 11 | Layout/ArgumentAlignment: 12 | EnforcedStyle: with_fixed_indentation 13 | Layout/MultilineOperationIndentation: 14 | EnforcedStyle: indented 15 | Layout/FirstHashElementIndentation: 16 | EnforcedStyle: consistent 17 | Layout/FirstArrayElementIndentation: 18 | EnforcedStyle: consistent 19 | Style/ConditionalAssignment: 20 | EnforcedStyle: assign_inside_condition 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in graphql-dataloader-activerecord.gemspec 6 | gemspec 7 | 8 | gem 'pry-byebug' 9 | gem 'rake', '~> 13.0' 10 | gem 'rspec', '~> 3.0' 11 | gem 'rubocop', '~> 1.21' 12 | gem 'sqlite3' 13 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | graphql-dataloader-activerecord (0.1.0) 5 | activerecord (>= 6.1) 6 | activesupport (>= 6.1) 7 | graphql (~> 2.0) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | activemodel (7.0.4) 13 | activesupport (= 7.0.4) 14 | activerecord (7.0.4) 15 | activemodel (= 7.0.4) 16 | activesupport (= 7.0.4) 17 | activesupport (7.0.4) 18 | concurrent-ruby (~> 1.0, >= 1.0.2) 19 | i18n (>= 1.6, < 2) 20 | minitest (>= 5.1) 21 | tzinfo (~> 2.0) 22 | ast (2.4.2) 23 | byebug (11.1.3) 24 | coderay (1.1.3) 25 | concurrent-ruby (1.1.10) 26 | diff-lcs (1.5.0) 27 | graphql (2.0.13) 28 | i18n (1.12.0) 29 | concurrent-ruby (~> 1.0) 30 | json (2.6.3) 31 | method_source (1.0.0) 32 | minitest (5.16.3) 33 | parallel (1.22.1) 34 | parser (3.1.3.0) 35 | ast (~> 2.4.1) 36 | pry (0.13.1) 37 | coderay (~> 1.1) 38 | method_source (~> 1.0) 39 | pry-byebug (3.9.0) 40 | byebug (~> 11.0) 41 | pry (~> 0.13.0) 42 | rainbow (3.1.1) 43 | rake (13.0.6) 44 | regexp_parser (2.6.1) 45 | rexml (3.2.5) 46 | rspec (3.12.0) 47 | rspec-core (~> 3.12.0) 48 | rspec-expectations (~> 3.12.0) 49 | rspec-mocks (~> 3.12.0) 50 | rspec-core (3.12.0) 51 | rspec-support (~> 3.12.0) 52 | rspec-expectations (3.12.1) 53 | diff-lcs (>= 1.2.0, < 2.0) 54 | rspec-support (~> 3.12.0) 55 | rspec-mocks (3.12.1) 56 | diff-lcs (>= 1.2.0, < 2.0) 57 | rspec-support (~> 3.12.0) 58 | rspec-support (3.12.0) 59 | rubocop (1.41.1) 60 | json (~> 2.3) 61 | parallel (~> 1.10) 62 | parser (>= 3.1.2.1) 63 | rainbow (>= 2.2.2, < 4.0) 64 | regexp_parser (>= 1.8, < 3.0) 65 | rexml (>= 3.2.5, < 4.0) 66 | rubocop-ast (>= 1.23.0, < 2.0) 67 | ruby-progressbar (~> 1.7) 68 | unicode-display_width (>= 1.4.0, < 3.0) 69 | rubocop-ast (1.24.0) 70 | parser (>= 3.1.1.0) 71 | ruby-progressbar (1.11.0) 72 | sqlite3 (1.4.4) 73 | tzinfo (2.0.5) 74 | concurrent-ruby (~> 1.0) 75 | unicode-display_width (2.3.0) 76 | 77 | PLATFORMS 78 | x86_64-darwin-20 79 | x86_64-linux 80 | 81 | DEPENDENCIES 82 | graphql-dataloader-activerecord! 83 | pry-byebug 84 | rake (~> 13.0) 85 | rspec (~> 3.0) 86 | rubocop (~> 1.21) 87 | sqlite3 88 | 89 | BUNDLED WITH 90 | 2.3.14 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-dataloader-activerecord 2 | 3 | This gem provides proxy objects in place of ActiveRecord objects where the 4 | proxy handles relationship loading through GraphQL::Dataloader. This is an 5 | experimental approach which should theoretically allow authors to write GraphQL 6 | code that relies on ActiveRecord using regular ActiveRecord relationship 7 | methods without generating N+1 query situations. 8 | 9 | ## Usage 10 | 11 | By including DataloaderRelationProxy::Lazy, your ActiveRecord-based type 12 | classes can transparently use efficient Dataloaders. For example, in the 13 | following example, N stories, and their authors can be authorized and loaded in 14 | a constant number of queries without changing the implementation of the types: 15 | 16 | ```ruby 17 | class Query < GraphQL::Schema::Object 18 | field :stories, ['Types::Story'] 19 | 20 | def stories 21 | ::Story.all 22 | end 23 | end 24 | 25 | class Story < GraphQL::Schema::Object 26 | include DataloaderRelationProxy::Lazy 27 | 28 | field :author, Types::User 29 | field :text, String 30 | 31 | def self.authorized?(object, context) 32 | # Even though it looks like we're loading the author here, object.author is 33 | # actually spawning a new fiber and yielding back to the GraphQL engine. 34 | # The return value is also chainable so we can continue to efficiently follow 35 | # ActiveRecord relationships as shown: 36 | return false unless object.author.plan.name == 'paid' 37 | 38 | # Arbitrary rule to force publication to load to demonstrate this 39 | # functionality 40 | return object.publication.present? 41 | end 42 | 43 | # There is no need to define an `author` method here since @object responds 44 | # to `author` already, but if we did, it would be: 45 | def author 46 | @object.author 47 | end 48 | end 49 | ``` 50 | 51 | ## Development 52 | 53 | After checking out the repo, run `bin/setup` to install dependencies. Then, run 54 | `rake spec` to run the tests. You can also run `bin/console` for an interactive 55 | prompt that will allow you to experiment. 56 | 57 | To install this gem onto your local machine, run `bundle exec rake install`. To 58 | release a new version, update the version number in `version.rb`, and then run 59 | `bundle exec rake release`, which will create a git tag for the version, push 60 | git commits and the created tag, and push the `.gem` file to 61 | [rubygems.org](https://rubygems.org). 62 | 63 | ## Contributing 64 | 65 | Please begin by filling out the contributor form and asserting that 66 | 67 | > The code I'm contributing is mine, and I have the right to license it. I'm 68 | > granting you a license to distribute said code under the terms of this 69 | > agreement. at this page: https://opensource.dropbox.com/cla/ 70 | 71 | Then create a new pull request through the github interface 72 | 73 | ## License 74 | 75 | Copyright (c) 2022 Dropbox, Inc. 76 | 77 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use 78 | this file except in compliance with the License. You may obtain a copy of the 79 | License at 80 | 81 | http://www.apache.org/licenses/LICENSE-2.0 82 | 83 | Unless required by applicable law or agreed to in writing, software distributed 84 | under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 85 | CONDITIONS OF ANY KIND, either express or implied. See the License for the 86 | specific language governing permissions and limitations under the License. 87 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require 'rubocop/rake_task' 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[spec rubocop] 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'graphql/dataloader/activerecord' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /graphql-dataloader-activerecord.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/dataloader_relation_proxy/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'graphql-dataloader-activerecord' 7 | spec.version = DataloaderRelationProxy::VERSION 8 | spec.authors = ['Stephen Crosby'] 9 | spec.email = ['stevecrozz@dropbox.com'] 10 | 11 | spec.summary = 'Prevent ActiveRecord N+1 queries within GraphQL' 12 | spec.description = <<~DESCRIPTION 13 | This gem provides proxy objects in place of ActiveRecord objects where the 14 | proxy handles relationship loading through GraphQL::Dataloader. This is an 15 | experimental approach which should theoretically allow authors to write GraphQL 16 | code that relies on ActiveRecord using regular ActiveRecord relationship 17 | methods without generating N+1 query situations. 18 | DESCRIPTION 19 | spec.homepage = 'https://github.com/dropbox/graphql-dataloader-activerecord' 20 | spec.required_ruby_version = '>= 2.7.0' 21 | 22 | spec.metadata['homepage_uri'] = spec.homepage 23 | spec.metadata['source_code_uri'] = spec.homepage 24 | 25 | spec.files = Dir.chdir(__dir__) do 26 | `git ls-files -z`.split("\x0").reject do |f| 27 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git))}) 28 | end 29 | end 30 | spec.require_paths = ['lib'] 31 | 32 | spec.add_dependency 'activerecord', '>= 6.1' 33 | spec.add_dependency 'activesupport', '>= 6.1' 34 | spec.add_dependency 'graphql', '~> 2.0' 35 | spec.metadata['rubygems_mfa_required'] = 'true' 36 | end 37 | -------------------------------------------------------------------------------- /lib/dataloader_relation_proxy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record' 4 | require 'graphql' 5 | require 'active_support/core_ext/module/delegation' 6 | require_relative 'dataloader_relation_proxy/collection' 7 | require_relative 'dataloader_relation_proxy/record' 8 | require_relative 'dataloader_relation_proxy/version' 9 | require_relative 'dataloader_relation_proxy/active_record_object' 10 | require_relative 'dataloader_relation_proxy/lazy' 11 | 12 | # Top level namespace for a system that proxies activerecord relationships 13 | # through GraphQL dataloaders. 14 | module DataloaderRelationProxy 15 | # Namespace to house the proxy classes so they are easily addressable 16 | Proxies = Module.new 17 | 18 | Error = Class.new(StandardError) 19 | 20 | DELEGATE_TO_MODEL_METHODS = %i[ 21 | === 22 | == 23 | eql? 24 | equal? 25 | <=> 26 | < 27 | > 28 | <= 29 | >= 30 | ].freeze 31 | 32 | @defined = Set.new 33 | 34 | # Defines a wrapper class for the provided model. This wrapper creates 35 | # classes that extend DataloaderRelationProxy::Record and then defines 36 | # relationship accessors that are efficiently batched by GraphQL::Dataloader. 37 | # 38 | # @param [Class] 39 | def self.define_for!(model) 40 | @defined << model 41 | 42 | # Recursively define the namespace and wrapper class 43 | klass = model.name.split('::').reduce(Proxies) do |memo, value| 44 | next memo.const_get(value, false) if memo.const_defined?(value, false) 45 | 46 | memo.const_set(value, Class.new(DataloaderRelationProxy::Record)) 47 | end 48 | 49 | define_belongs_to_accessors!(klass, model) 50 | define_has_many_accessors!(klass, model) 51 | delegate_class_methods!(klass, model) 52 | catch_remaining_methods!(klass) 53 | end 54 | 55 | # Determine if there is already a wrapper class defined for the given model. 56 | # 57 | # @param [Class] 58 | def self.defined_for?(model) 59 | @defined.include?(model) 60 | end 61 | 62 | # Ensure a wrapper class is defined for the given model and return it. 63 | # 64 | # @param [Class] 65 | def self.for(model) 66 | define_for!(model) unless defined_for?(model) 67 | 68 | DataloaderRelationProxy::Proxies.const_get(model.name, false) 69 | end 70 | 71 | # Given an activerecord model and wrapper class, define an accessor on the 72 | # wrapper class for each belongs_to relationship on the model driven by 73 | # GraphQL::Datalaoder. 74 | # 75 | # @param [DataloaderRelationProxy::Record] wrapper class to define 76 | # accessors 77 | # @param [Class] underlying model 78 | def self.define_belongs_to_accessors!(klass, model) 79 | return unless model.respond_to?(:reflect_on_all_associations) 80 | 81 | model.reflect_on_all_associations(:belongs_to).each do |reflection| 82 | # not sure how to handle this yet 83 | next if reflection.polymorphic? 84 | 85 | self.for(reflection.klass) 86 | 87 | klass.instance_eval do 88 | DataloaderRelationProxy.activerecord_belongs_to(klass, reflection) 89 | end 90 | end 91 | end 92 | 93 | # Given an activerecord model and wrapper class, define an accessor on the 94 | # wrapper class for each has_many relationship on the model driven by 95 | # GraphQL::Datalaoder. 96 | # 97 | # @param [DataloaderRelationProxy::Record] wrapper class to define 98 | # accessors 99 | # @param [Class] underlying model 100 | def self.define_has_many_accessors!(klass, model) 101 | return unless model.respond_to?(:reflect_on_all_associations) 102 | 103 | model.reflect_on_all_associations(:has_many).each do |reflection| 104 | # not sure how to handle this yet 105 | next if reflection.polymorphic? 106 | 107 | klass.instance_eval do 108 | DataloaderRelationProxy.activerecord_has_many(klass, reflection) 109 | end 110 | end 111 | end 112 | 113 | # Delegates some class methods to the underlying model class so that 114 | # instances of this proxy can more readily stand in for a model instance. 115 | # 116 | # @param [DataloaderRelationProxy::Record] proxy class 117 | # @param [Class] underlying model 118 | def self.delegate_class_methods!(klass, model) 119 | DELEGATE_TO_MODEL_METHODS.each do |m| 120 | klass.define_singleton_method(m) do |other| 121 | model.send(m, other) 122 | end 123 | end 124 | end 125 | 126 | # rubocop:disable Metrics/MethodLength 127 | def self.catch_remaining_methods!(klass) 128 | klass.instance_eval do 129 | define_method(:method_missing) do |method_name, *args, &block| 130 | raise NoMethodError, "Missing method '#{method_name}' on #{@object}" unless @object.respond_to?(method_name) 131 | 132 | self.class.instance_eval do 133 | delegate method_name, to: :@object 134 | end 135 | 136 | @object.send(method_name, *args, &block) 137 | end 138 | 139 | define_method(:respond_to_missing?) do |method_name, include_private = false| 140 | @object.respond_to?(method_name) || super(method_name, include_private) 141 | end 142 | end 143 | end 144 | # rubocop:enable Metrics/MethodLength 145 | 146 | # Define a getter that works something like an ActiveRecord belongs_to 147 | # relationship, except using a dataloader. 148 | def self.activerecord_belongs_to(klass, reflection) 149 | klass.define_method(reflection.name) do 150 | instance = @dataloader.with( 151 | ActiveRecordObject, 152 | reflection.klass, 153 | reflection.association_primary_key 154 | ).load(@object.send(reflection.foreign_key)) 155 | return nil if instance.nil? 156 | 157 | DataloaderRelationProxy.for(instance.class).new(instance, @dataloader) 158 | end 159 | end 160 | 161 | # Define a getter that works something like an ActiveRecord has_many 162 | # relationship, except using a dataloader. 163 | def self.activerecord_has_many(klass, reflection) 164 | klass.define_method(reflection.name) do 165 | Collection.new(@object.send(reflection.name), @dataloader) 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /lib/dataloader_relation_proxy/active_record_object.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DataloaderRelationProxy 4 | # Dataloader that loads ActiveRecord objects by an arbitrary key 5 | class ActiveRecordObject < GraphQL::Dataloader::Source 6 | def initialize(model_class, key = :id) 7 | super() 8 | @model_class = model_class 9 | @key = key 10 | end 11 | 12 | def fetch(ids) 13 | records = @model_class.where(@key => ids) 14 | by_id = records.index_by { |record| record.send(@key) } 15 | ids.map { |id| by_id[id] } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/dataloader_relation_proxy/collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DataloaderRelationProxy 4 | # Represents an ActiveRecord collection and provides an enumerable that 5 | # returns underlying objects that are instances of 6 | # DataloaderRelationProxy::Record 7 | class Collection < Object 8 | def initialize(object, dataloader) 9 | @object = object 10 | @dataloader = dataloader 11 | 12 | # Need to ensure the collection elements are wrapped in 13 | # DataloaderRelationProxy objects. I'm sure there's a nicer alternative. 14 | # 15 | # rubocop:disable Lint/NestedMethodDefinition 16 | def @object.load_target 17 | @association.load_target.map do |record| 18 | DataloaderRelationProxy.for(record.class).new(record, @dataloader) 19 | end 20 | end 21 | # rubocop:enable Lint/NestedMethodDefinition 22 | end 23 | 24 | def method_missing(method_name, *args, &block) 25 | raise NoMethodError, "Missing method '#{method_name}' on #{@object}" unless @object.respond_to?(method_name) 26 | 27 | result = @object.send(method_name, *args, &block) 28 | if result.is_a?(ActiveRecord::Base) 29 | DataloaderRelationProxy.for(result.class).new(result, @dataloader) 30 | else 31 | result 32 | end 33 | end 34 | 35 | def respond_to_missing?(method_name, _include_private = false) 36 | @object.respond_to?(method_name) 37 | end 38 | 39 | def loaded? 40 | true 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/dataloader_relation_proxy/lazy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DataloaderRelationProxy 4 | # Including Lazy causes two changes for type classes. 5 | # 6 | # 1) Type.authorized? receives a relationship proxy object rather than the 7 | # actual object as its first parameter. 8 | # 2) Type#rel is provided so that type instances can easily build and access 9 | # a relationship proxy. 10 | # 11 | # @see DataloaderRelationProxy for information about what benefits are 12 | # provided 13 | module Lazy 14 | extend ActiveSupport::Concern 15 | 16 | # Prepended to type classes in order to change the authorized? interface 17 | module Authorizer 18 | def authorized?(object, context) 19 | if object.is_a?(DataloaderRelationProxy) 20 | super(object, context) 21 | else 22 | super(DataloaderRelationProxy.for(object.class).new(object, context.dataloader), context) 23 | end 24 | end 25 | end 26 | 27 | included do |parent| 28 | parent.singleton_class.prepend(Authorizer) 29 | end 30 | 31 | def object 32 | if @object.is_a?(DataloaderRelationProxy) 33 | @object 34 | else 35 | DataloaderRelationProxy.for(@object.class).new(@object, context.dataloader) 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/dataloader_relation_proxy/record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DataloaderRelationProxy 4 | # Wraps ActiveRecord records and provides accessors for 5 | # loading relationships via GraphQL::Dataloader 6 | class Record < Object 7 | delegate :to_param, to: :@object 8 | 9 | def initialize(object, dataloader) 10 | @object = object 11 | @dataloader = dataloader 12 | end 13 | 14 | # Allows comparison with other wrappers or their underlying objects 15 | # 16 | # @param [DataloaderRelationProxy::Record] other wrapper or model instance 17 | def ==(other) 18 | if other.is_a?(DataloaderRelationProxy::Record) 19 | comparator = other.instance_variable_get(:@object) 20 | else 21 | comparator = other 22 | end 23 | 24 | @object.send(:==, comparator) 25 | end 26 | 27 | def is_a?(thing) 28 | super || @object.send(:is_a?, thing) 29 | end 30 | 31 | def loaded? 32 | true 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/dataloader_relation_proxy/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DataloaderRelationProxy 4 | VERSION = '0.1.0' 5 | end 6 | -------------------------------------------------------------------------------- /spec/dataloader_relation_proxy/lazy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe DataloaderRelationProxy::Lazy do 4 | describe '#object' do 5 | it 'loads multiple relations in one query' do 6 | query_string = <<-GRAPHQL 7 | query { 8 | stories { 9 | author { 10 | name 11 | } 12 | } 13 | } 14 | GRAPHQL 15 | database_queries = 0 16 | callback = ->(*) { database_queries += 1 } 17 | ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') do 18 | TestImplementationSchema.execute(query_string) 19 | end 20 | 21 | # + 1 query for the stories 22 | # + 1 query for the authors (needed for authors hop and authorization) 23 | # + 1 query for publications (arbitrarily loaded by the authorization) 24 | # --- 25 | # = 3 26 | expect(database_queries).to eq(3) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/dataloader_relation_proxy/record_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe DataloaderRelationProxy::Record do 4 | let(:alice) { User.find_by_name('alice') } 5 | let(:bob) { User.find_by_name('bob') } 6 | 7 | describe 'belongs_to' do 8 | it 'loads multiple relations in one query' do 9 | story1 = alice.stories.first 10 | story2 = bob.stories.first 11 | 12 | database_queries = 0 13 | callback = ->(*) { database_queries += 1 } 14 | ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') do 15 | authors = GraphQL::Dataloader.with_dataloading do |dataloader| 16 | proxy1 = DataloaderRelationProxy.for(Story).new(story1, dataloader) 17 | proxy2 = DataloaderRelationProxy.for(Story).new(story2, dataloader) 18 | 19 | dataloader.append_job { proxy1.author } 20 | dataloader.append_job { proxy2.author } 21 | 22 | [proxy1.author.name, proxy2.author.name] 23 | end 24 | 25 | expect(authors).to eq(%w[alice bob]) 26 | end 27 | 28 | expect(database_queries).to eq(1) 29 | end 30 | 31 | it 'supports chaining' do 32 | story1 = alice.stories.first 33 | story2 = bob.stories.first 34 | 35 | database_queries = 0 36 | callback = ->(*) { database_queries += 1 } 37 | ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') do 38 | plans = GraphQL::Dataloader.with_dataloading do |dataloader| 39 | proxy1 = DataloaderRelationProxy.for(Story).new(story1, dataloader) 40 | proxy2 = DataloaderRelationProxy.for(Story).new(story2, dataloader) 41 | 42 | dataloader.append_job { proxy1.author.plan } 43 | dataloader.append_job { proxy2.author.plan } 44 | 45 | [proxy1.author.plan.name, proxy2.author.plan.name] 46 | end 47 | 48 | expect(plans).to eq(%w[trial personal]) 49 | end 50 | 51 | expect(database_queries).to eq(2) 52 | end 53 | end 54 | 55 | describe 'has_many' do 56 | it 'loads a collection-like proxy object' do 57 | stories = GraphQL::Dataloader.with_dataloading do |dataloader| 58 | proxy = DataloaderRelationProxy.for(User).new(alice, dataloader) 59 | 60 | dataloader.append_job { proxy.stories } 61 | proxy.stories.map(&:text) 62 | end 63 | 64 | expect(stories).to eq([ 65 | '2022 Year in Review', 66 | "Alice's Second Story" 67 | ]) 68 | end 69 | 70 | it 'supports chaining' do 71 | publications = GraphQL::Dataloader.with_dataloading do |dataloader| 72 | proxy1 = DataloaderRelationProxy.for(User).new(alice, dataloader) 73 | proxy2 = DataloaderRelationProxy.for(User).new(bob, dataloader) 74 | 75 | dataloader.append_job { proxy1.stories.first.publication } 76 | dataloader.append_job { proxy2.stories.first.publication } 77 | 78 | [ 79 | proxy1.stories.first.publication.name, 80 | proxy2.stories.first.publication.name 81 | ] 82 | end 83 | 84 | expect(publications).to eq([ 85 | 'Fresno Bee', 86 | 'Miami Herald' 87 | ]) 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/dataloader_relation_proxy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe DataloaderRelationProxy do 4 | it 'has a version number' do 5 | expect(DataloaderRelationProxy::VERSION).not_to be nil 6 | end 7 | 8 | describe '.defined_for?' do 9 | before do 10 | Object.const_set('FooNotRegistered', Class.new) 11 | Object.const_set('FooRegistered', Class.new) 12 | subject.define_for!(FooRegistered) 13 | end 14 | 15 | after do 16 | Object.send(:remove_const, 'FooRegistered') 17 | Object.send(:remove_const, 'FooNotRegistered') 18 | end 19 | 20 | it 'returns false when a proxy is not defined' do 21 | expect(described_class.defined_for?(FooNotRegistered)).to be(false) 22 | end 23 | 24 | it 'returns true when a proxy is defined' do 25 | expect(described_class.defined_for?(FooRegistered)).to be(true) 26 | end 27 | end 28 | 29 | describe '.for' do 30 | let(:subject) { described_class.for(User) } 31 | 32 | it 'returns a proxy class' do 33 | expect(subject.name).to include('DataloaderRelationProxy::Proxies') 34 | end 35 | 36 | it 'pretends it is the underlying model class if anyone asks' do 37 | expect(subject).to be(User) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/model_fixtures.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Base.establish_connection( 4 | adapter: 'sqlite3', 5 | database: ':memory:' 6 | ) 7 | 8 | # ActiveRecord::Base.logger = Logger.new($stdout) 9 | 10 | class User < ActiveRecord::Base 11 | has_many :teams, through: :memberships 12 | has_many :stories, foreign_key: :author_id 13 | belongs_to :plan 14 | end 15 | 16 | class Team < ActiveRecord::Base 17 | has_many :memberships 18 | has_many :users, through: :memberships 19 | end 20 | 21 | class Membership < ActiveRecord::Base 22 | belongs_to :user 23 | belongs_to :team 24 | end 25 | 26 | class Story < ActiveRecord::Base 27 | belongs_to :author, class_name: 'User' 28 | belongs_to :publication 29 | end 30 | 31 | class Plan < ActiveRecord::Base 32 | end 33 | 34 | class Publication < ActiveRecord::Base 35 | has_many :stories 36 | end 37 | 38 | ActiveRecord::Schema.define(version: 1) do 39 | create_table :users do |t| 40 | t.text :name 41 | t.integer :plan_id 42 | end 43 | 44 | create_table :teams do |t| 45 | t.text :name 46 | end 47 | 48 | create_table :memberships do |t| 49 | t.integer :user_id, null: false 50 | t.integer :team_id, null: false 51 | end 52 | 53 | create_table :publications do |t| 54 | t.text :name 55 | end 56 | 57 | create_table :stories do |t| 58 | t.integer :author_id, null: false 59 | t.integer :publication_id, null: false 60 | t.text :text 61 | end 62 | 63 | create_table :plans do |t| 64 | t.text :name 65 | end 66 | end 67 | 68 | trial_plan = Plan.create(name: 'trial') 69 | personal_plan = Plan.create(name: 'personal') 70 | alice = User.create(name: 'alice', plan: trial_plan) 71 | bob = User.create(name: 'bob', plan: personal_plan) 72 | fresno_bee = Publication.create(name: 'Fresno Bee') 73 | miami_herald = Publication.create(name: 'Miami Herald') 74 | Story.create(author: alice, text: '2022 Year in Review', publication: fresno_bee) 75 | Story.create(author: alice, text: "Alice's Second Story", publication: fresno_bee) 76 | Story.create(author: bob, text: 'Spec Author Not Creative', publication: miami_herald) 77 | Story.create(author: bob, text: "Bob's Second Story", publication: miami_herald) 78 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pry' 4 | require 'dataloader_relation_proxy' 5 | require_relative 'model_fixtures' 6 | require_relative 'test_implementation/test_implementation_schema' 7 | 8 | RSpec.configure do |config| 9 | # Enable flags like --only-failures and --next-failure 10 | config.example_status_persistence_file_path = '.rspec_status' 11 | 12 | # Disable RSpec exposing methods globally on `Module` and `main` 13 | config.disable_monkey_patching! 14 | 15 | config.expect_with :rspec do |c| 16 | c.syntax = :expect 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/test_implementation/test_implementation_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Types 4 | class Query < GraphQL::Schema::Object 5 | field :stories, ['Types::Story'] 6 | 7 | def stories 8 | ::Story.all 9 | end 10 | end 11 | 12 | class User < GraphQL::Schema::Object 13 | field :name, String 14 | end 15 | 16 | class Story < GraphQL::Schema::Object 17 | include DataloaderRelationProxy::Lazy 18 | 19 | def self.authorized?(object, *) 20 | # force the author to load for testing purposes 21 | object.author.present? 22 | 23 | # Arbitrary rule to force publication to load 24 | ['Fresno Bee', 'Miami Herald'].include?(object.publication.name) 25 | end 26 | 27 | field :author, Types::User 28 | field :text, String 29 | end 30 | end 31 | 32 | class TestImplementationSchema < GraphQL::Schema 33 | query Types::Query 34 | use GraphQL::Dataloader 35 | end 36 | --------------------------------------------------------------------------------