├── .codeclimate.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── Appraisals ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── LICENSE.txt ├── README.md ├── RELEASE.md ├── Rakefile ├── ams_lazy_relationships.gemspec ├── bin ├── console └── setup ├── gemfiles ├── .bundle │ └── config ├── ams_0.10.0.gemfile ├── ams_0.10.0.rc4.gemfile ├── ams_0.10.10.gemfile ├── ams_0.10.2.gemfile ├── ams_0.10.3.gemfile ├── ams_0.10.8.gemfile ├── batch_loader_1.gemfile ├── batch_loader_2.gemfile ├── rails_6.gemfile └── rails_7.gemfile ├── lib ├── ams_lazy_relationships.rb └── ams_lazy_relationships │ ├── core.rb │ ├── core │ ├── evaluation.rb │ ├── lazy_dig_method.rb │ ├── lazy_relationship_meta.rb │ ├── lazy_relationship_method.rb │ └── relationship_wrapper_methods.rb │ ├── extensions.rb │ ├── extensions │ └── reflection.rb │ ├── loaders.rb │ ├── loaders │ ├── association.rb │ ├── base.rb │ ├── direct.rb │ ├── simple_belongs_to.rb │ └── simple_has_many.rb │ └── version.rb └── spec ├── ams_lazy_relationships_spec.rb ├── benchmark_spec.rb ├── core_spec.rb ├── loaders ├── association_spec.rb ├── direct_spec.rb ├── simple_belongs_to_spec.rb └── simple_has_many_spec.rb ├── spec_helper.rb └── support └── with_ar_models.rb /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | # This is a sample .codeclimate.yml configured for Plugin analysis on Code 2 | # Climate Platform. For an overview of the Code Climate Platform, see here: 3 | # https://codeclimate.com/blog/code-climate-platform/ 4 | 5 | # Under the plugins key, you can configure which plugins will analyze your repo. 6 | # Each key is a plugin name. For each value, you need to specify enabled: true 7 | # to enable the plugin as well as any other plugins-specific configuration. 8 | 9 | # For more details, see here: 10 | # http://docs.codeclimate.com/article/289-configuring-your-repository-via-codeclimate-yml#platform 11 | 12 | version: "2" 13 | 14 | # For a list of all available plugins, see here: 15 | # https://docs.codeclimate.com/docs/list-of-engines 16 | 17 | plugins: 18 | # to turn on a plugin, add it here and set enabled to `true` 19 | # to turn off a plugin, set enabled to `false` or remove it 20 | rubocop: 21 | enabled: true 22 | channel: rubocop-0-51 23 | checks: 24 | Rubocop/Metrics/LineLength: 25 | enabled: false 26 | bundler-audit: 27 | enabled: true 28 | duplication: 29 | enabled: true 30 | exclude_patterns: 31 | - spec 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Rails Unit Tests 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - name: Set up Ruby 3.1.2 9 | uses: ruby/setup-ruby@v1 10 | with: 11 | ruby-version: 3.1.2 12 | - name: Build and test with Rake 13 | env: 14 | RAILS_ENV: test 15 | run: | 16 | gem install bundler 17 | bundle install --jobs 4 --retry 3 18 | bundle exec rspec 19 | ruby -e "$(curl -s https://undercover-ci.com/uploader.rb)" -- \ 20 | --repo ${{ github.repository }} \ 21 | --commit ${{ github.workflow_sha }} \ 22 | --lcov coverage/lcov/ams_lazy_relationships.lcov 23 | -------------------------------------------------------------------------------- /.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 | 13 | gemfiles/*.lock 14 | .DS_Store 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: rubocop-rspec 2 | 3 | AllCops: 4 | Exclude: 5 | - db/schema.rb 6 | - Gemfile 7 | TargetRubyVersion: 2.3 8 | TargetRailsVersion: 4.2 9 | 10 | Metrics/ClassLength: 11 | Max: 300 12 | 13 | Metrics/MethodLength: 14 | Enabled: false 15 | 16 | Metrics/ParameterLists: 17 | Enabled: false 18 | 19 | Metrics/BlockLength: 20 | Enabled: false 21 | 22 | Layout/AccessModifierIndentation: 23 | Enabled: false 24 | 25 | Style/ClassAndModuleChildren: 26 | Enabled: false 27 | 28 | Style/Documentation: 29 | Enabled: false 30 | 31 | Layout/DotPosition: 32 | Enabled: true 33 | EnforcedStyle: trailing 34 | 35 | Style/StringLiterals: 36 | Enabled: true 37 | EnforcedStyle: double_quotes 38 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sudo: false 3 | env: 4 | global: 5 | - CC_TEST_REPORTER_ID=1da1af59767d8c64ae960d5a0ac69089a0fa90e013aedb09e76843f104bec709 6 | language: ruby 7 | gemfile: 8 | - gemfiles/ams_0.10.0.rc4.gemfile 9 | - gemfiles/ams_0.10.0.gemfile 10 | - gemfiles/ams_0.10.2.gemfile 11 | - gemfiles/ams_0.10.3.gemfile 12 | - gemfiles/ams_0.10.8.gemfile 13 | - gemfiles/ams_0.10.10.gemfile 14 | - gemfiles/batch_loader_1.gemfile 15 | - gemfiles/batch_loader_2.gemfile 16 | - gemfiles/rails_6.gemfile 17 | - gemfiles/rails_7.gemfile 18 | cache: bundler 19 | rvm: 20 | - 2.5.3 21 | - 3.0.0 22 | jobs: 23 | # Following AMS versions are locked to AR < 6 which isn't compatible with Ruby 3 24 | exclude: 25 | - rvm: 3.0.0 26 | gemfile: gemfiles/ams_0.10.2.gemfile 27 | - rvm: 3.0.0 28 | gemfile: gemfiles/ams_0.10.3.gemfile 29 | - rvm: 3.0.0 30 | gemfile: gemfiles/ams_0.10.8.gemfile 31 | before_script: 32 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 33 | - chmod +x ./cc-test-reporter 34 | - ./cc-test-reporter before-build 35 | script: 36 | - bundle exec rake 37 | after_script: 38 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT 39 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "ams-0.10.0.rc4" do 2 | gem "active_model_serializers", "0.10.0.rc4" 3 | end 4 | 5 | appraise "ams-0.10.0" do 6 | gem "active_model_serializers", "0.10.0" 7 | end 8 | 9 | appraise "ams-0.10.2" do 10 | gem "active_model_serializers", "0.10.2" 11 | end 12 | 13 | appraise "ams-0.10.3" do 14 | gem "active_model_serializers", "0.10.3" 15 | end 16 | 17 | appraise "ams-0.10.8" do 18 | gem "active_model_serializers", "0.10.8" 19 | end 20 | 21 | appraise "ams-0.10.10" do 22 | gem "active_model_serializers", "0.10.10" 23 | end 24 | 25 | appraise "batch-loader-1" do 26 | gem "batch-loader", "~> 1" 27 | end 28 | 29 | appraise "batch-loader-2" do 30 | gem "batch-loader", "~> 2" 31 | end 32 | 33 | appraise "rails-6" do 34 | gem "activerecord", "~> 6" 35 | end 36 | 37 | appraise "rails-7" do 38 | gem "activerecord", "~> 7" 39 | end 40 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v0.4.0](https://github.com/Bajena/ams_lazy_relationships/tree/v0.4.0) (2023-11-18) 4 | 5 | [Full Changelog](https://github.com/Bajena/ams_lazy_relationships/compare/v0.3.2...v0.4.0) 6 | 7 | **Closed issues:** 8 | 9 | - How do you override association methods? [\#68](https://github.com/Bajena/ams_lazy_relationships/issues/68) 10 | - Throws exception in ruby 3.0.0 [\#58](https://github.com/Bajena/ams_lazy_relationships/issues/58) 11 | 12 | **Merged pull requests:** 13 | 14 | - Rails 7 support [\#85](https://github.com/Bajena/ams_lazy_relationships/pull/85) ([stokarenko](https://github.com/stokarenko)) 15 | - Bump addressable from 2.7.0 to 2.8.0 [\#67](https://github.com/Bajena/ams_lazy_relationships/pull/67) ([dependabot[bot]](https://github.com/apps/dependabot)) 16 | - Bump nokogiri from 1.11.3 to 1.11.4 [\#66](https://github.com/Bajena/ams_lazy_relationships/pull/66) ([dependabot[bot]](https://github.com/apps/dependabot)) 17 | 18 | ## [v0.3.2](https://github.com/Bajena/ams_lazy_relationships/tree/v0.3.2) (2021-04-18) 19 | 20 | [Full Changelog](https://github.com/Bajena/ams_lazy_relationships/compare/v0.3.1...v0.3.2) 21 | 22 | **Merged pull requests:** 23 | 24 | - Support Ruby 3 [\#64](https://github.com/Bajena/ams_lazy_relationships/pull/64) ([Bajena](https://github.com/Bajena)) 25 | - Bump nokogiri from 1.10.5 to 1.11.3 [\#63](https://github.com/Bajena/ams_lazy_relationships/pull/63) ([dependabot[bot]](https://github.com/apps/dependabot)) 26 | - Bump json from 2.1.0 to 2.5.1 [\#61](https://github.com/Bajena/ams_lazy_relationships/pull/61) ([dependabot[bot]](https://github.com/apps/dependabot)) 27 | - Update rake requirement from ~\> 10.0 to ~\> 13.0 [\#53](https://github.com/Bajena/ams_lazy_relationships/pull/53) ([dependabot[bot]](https://github.com/apps/dependabot)) 28 | 29 | ## [v0.3.1](https://github.com/Bajena/ams_lazy_relationships/tree/v0.3.1) (2021-04-14) 30 | 31 | [Full Changelog](https://github.com/Bajena/ams_lazy_relationships/compare/v0.3.0...v0.3.1) 32 | 33 | **Closed issues:** 34 | 35 | - NameError: uninitialized constant ActiveModel::Serializer::Reflection [\#59](https://github.com/Bajena/ams_lazy_relationships/issues/59) 36 | 37 | **Merged pull requests:** 38 | 39 | - Lock minimum AMS version to 0.10.0.rc4 [\#60](https://github.com/Bajena/ams_lazy_relationships/pull/60) ([Bajena](https://github.com/Bajena)) 40 | 41 | ## [v0.3.0](https://github.com/Bajena/ams_lazy_relationships/tree/v0.3.0) (2020-01-16) 42 | 43 | [Full Changelog](https://github.com/Bajena/ams_lazy_relationships/compare/v0.2.0...v0.3.0) 44 | 45 | **Merged pull requests:** 46 | 47 | - Fix association duplicates after accepts\_nested\_attributes\_for assignation [\#50](https://github.com/Bajena/ams_lazy_relationships/pull/50) ([stokarenko](https://github.com/stokarenko)) 48 | - Skip active\_support as redundant dependency [\#49](https://github.com/Bajena/ams_lazy_relationships/pull/49) ([stokarenko](https://github.com/stokarenko)) 49 | - Test against AMS v0.10.0.rc4 [\#48](https://github.com/Bajena/ams_lazy_relationships/pull/48) ([stokarenko](https://github.com/stokarenko)) 50 | - Skip redundant queries when include\_data is disabled [\#47](https://github.com/Bajena/ams_lazy_relationships/pull/47) ([stokarenko](https://github.com/stokarenko)) 51 | - Adjust tested ams version [\#46](https://github.com/Bajena/ams_lazy_relationships/pull/46) ([stokarenko](https://github.com/stokarenko)) 52 | - Fix documentation for blocked relationships [\#45](https://github.com/Bajena/ams_lazy_relationships/pull/45) ([stokarenko](https://github.com/stokarenko)) 53 | 54 | ## [v0.2.0](https://github.com/Bajena/ams_lazy_relationships/tree/v0.2.0) (2020-01-11) 55 | 56 | [Full Changelog](https://github.com/Bajena/ams_lazy_relationships/compare/v0.1.5...v0.2.0) 57 | 58 | ## [v0.1.5](https://github.com/Bajena/ams_lazy_relationships/tree/v0.1.5) (2020-01-08) 59 | 60 | [Full Changelog](https://github.com/Bajena/ams_lazy_relationships/compare/v0.1.4...v0.1.5) 61 | 62 | **Closed issues:** 63 | 64 | - Extract a base class for the loaders [\#39](https://github.com/Bajena/ams_lazy_relationships/issues/39) 65 | - "Association" loader loads unnecessary records on AR 5.2.3+ [\#37](https://github.com/Bajena/ams_lazy_relationships/issues/37) 66 | - undefined method `load\_all\_lazy\_relationships' for nil:NilClass [\#30](https://github.com/Bajena/ams_lazy_relationships/issues/30) 67 | - Convert loaders to use strings instead of records as main keys [\#24](https://github.com/Bajena/ams_lazy_relationships/issues/24) 68 | 69 | **Merged pull requests:** 70 | 71 | - Improve tests for nested serializer lookup [\#43](https://github.com/Bajena/ams_lazy_relationships/pull/43) ([stokarenko](https://github.com/stokarenko)) 72 | - Extract a base class for lazy loaders [\#40](https://github.com/Bajena/ams_lazy_relationships/pull/40) ([Bajena](https://github.com/Bajena)) 73 | - Filter out preloaded records in `Association` preloader [\#36](https://github.com/Bajena/ams_lazy_relationships/pull/36) ([Bajena](https://github.com/Bajena)) 74 | - Synchronize lazy relationships [\#35](https://github.com/Bajena/ams_lazy_relationships/pull/35) ([stokarenko](https://github.com/stokarenko)) 75 | - Fix nested serializer lookup [\#34](https://github.com/Bajena/ams_lazy_relationships/pull/34) ([stokarenko](https://github.com/stokarenko)) 76 | - Fix batch loader dependency [\#33](https://github.com/Bajena/ams_lazy_relationships/pull/33) ([stokarenko](https://github.com/stokarenko)) 77 | - Bump rack from 2.0.6 to 2.0.8 [\#32](https://github.com/Bajena/ams_lazy_relationships/pull/32) ([dependabot[bot]](https://github.com/apps/dependabot)) 78 | - Bump loofah from 2.2.3 to 2.3.1 [\#31](https://github.com/Bajena/ams_lazy_relationships/pull/31) ([dependabot[bot]](https://github.com/apps/dependabot)) 79 | 80 | ## [v0.1.4](https://github.com/Bajena/ams_lazy_relationships/tree/v0.1.4) (2019-06-02) 81 | 82 | [Full Changelog](https://github.com/Bajena/ams_lazy_relationships/compare/v0.1.3...v0.1.4) 83 | 84 | **Closed issues:** 85 | 86 | - Use replace\_methods: false by default in loaders [\#28](https://github.com/Bajena/ams_lazy_relationships/issues/28) 87 | - Require less restrictive batch-loader version [\#25](https://github.com/Bajena/ams_lazy_relationships/issues/25) 88 | - Profile time and memory usage [\#21](https://github.com/Bajena/ams_lazy_relationships/issues/21) 89 | - Loading circular relationships [\#20](https://github.com/Bajena/ams_lazy_relationships/issues/20) 90 | - Add railtie [\#19](https://github.com/Bajena/ams_lazy_relationships/issues/19) 91 | 92 | **Merged pull requests:** 93 | 94 | - Use replace\_methods: false in batch loaders [\#29](https://github.com/Bajena/ams_lazy_relationships/pull/29) ([Bajena](https://github.com/Bajena)) 95 | - Add benchmark for speed & memory usage [\#27](https://github.com/Bajena/ams_lazy_relationships/pull/27) ([Bajena](https://github.com/Bajena)) 96 | - Require less restrictive batch loader version [\#26](https://github.com/Bajena/ams_lazy_relationships/pull/26) ([Bajena](https://github.com/Bajena)) 97 | 98 | ## [v0.1.3](https://github.com/Bajena/ams_lazy_relationships/tree/v0.1.3) (2019-05-19) 99 | 100 | [Full Changelog](https://github.com/Bajena/ams_lazy_relationships/compare/0.1.2...v0.1.3) 101 | 102 | **Closed issues:** 103 | 104 | - Association loader shouldn't yield cached associations data instantly [\#22](https://github.com/Bajena/ams_lazy_relationships/issues/22) 105 | - Customize loading behavior [\#14](https://github.com/Bajena/ams_lazy_relationships/issues/14) 106 | 107 | **Merged pull requests:** 108 | 109 | - Do not yield cached associations data instantly in Association loader [\#23](https://github.com/Bajena/ams_lazy_relationships/pull/23) ([Bajena](https://github.com/Bajena)) 110 | 111 | ## [0.1.2](https://github.com/Bajena/ams_lazy_relationships/tree/0.1.2) (2019-03-10) 112 | 113 | [Full Changelog](https://github.com/Bajena/ams_lazy_relationships/compare/v0.1.1...0.1.2) 114 | 115 | **Closed issues:** 116 | 117 | - Broken sqlite dependency [\#16](https://github.com/Bajena/ams_lazy_relationships/issues/16) 118 | 119 | **Merged pull requests:** 120 | 121 | - Add tests for lazy relationships inheritance [\#18](https://github.com/Bajena/ams_lazy_relationships/pull/18) ([Bajena](https://github.com/Bajena)) 122 | - Lock sqlite dependency [\#17](https://github.com/Bajena/ams_lazy_relationships/pull/17) ([Bajena](https://github.com/Bajena)) 123 | - Fix superclass lazy relationships not loading properly on subclass [\#15](https://github.com/Bajena/ams_lazy_relationships/pull/15) ([willcosgrove](https://github.com/willcosgrove)) 124 | 125 | ## [v0.1.1](https://github.com/Bajena/ams_lazy_relationships/tree/v0.1.1) (2019-01-09) 126 | 127 | [Full Changelog](https://github.com/Bajena/ams_lazy_relationships/compare/v0.1.0...v0.1.1) 128 | 129 | **Merged pull requests:** 130 | 131 | - Relax batch-loader version [\#13](https://github.com/Bajena/ams_lazy_relationships/pull/13) ([Bajena](https://github.com/Bajena)) 132 | 133 | ## [v0.1.0](https://github.com/Bajena/ams_lazy_relationships/tree/v0.1.0) (2018-12-30) 134 | 135 | [Full Changelog](https://github.com/Bajena/ams_lazy_relationships/compare/a045c7e1c2d545a336cb79fec9e92f0f4c843651...v0.1.0) 136 | 137 | **Closed issues:** 138 | 139 | - Add changelog [\#10](https://github.com/Bajena/ams_lazy_relationships/issues/10) 140 | - Prepare initial version + test suite [\#2](https://github.com/Bajena/ams_lazy_relationships/issues/2) 141 | - Test multiple AMS versions [\#1](https://github.com/Bajena/ams_lazy_relationships/issues/1) 142 | 143 | **Merged pull requests:** 144 | 145 | - Add undercover back [\#12](https://github.com/Bajena/ams_lazy_relationships/pull/12) ([Bajena](https://github.com/Bajena)) 146 | - Add changelog [\#11](https://github.com/Bajena/ams_lazy_relationships/pull/11) ([Bajena](https://github.com/Bajena)) 147 | - Split methods logically, add yard comments and hide unnecessary public methods [\#9](https://github.com/Bajena/ams_lazy_relationships/pull/9) ([Bajena](https://github.com/Bajena)) 148 | - Code cleanup [\#8](https://github.com/Bajena/ams_lazy_relationships/pull/8) ([Bajena](https://github.com/Bajena)) 149 | - Add tests for JSON adapter and improve backwards compatibility [\#7](https://github.com/Bajena/ams_lazy_relationships/pull/7) ([Bajena](https://github.com/Bajena)) 150 | - Use Appraisal to test different versions of AMS [\#6](https://github.com/Bajena/ams_lazy_relationships/pull/6) ([Bajena](https://github.com/Bajena)) 151 | - Add core module [\#5](https://github.com/Bajena/ams_lazy_relationships/pull/5) ([Bajena](https://github.com/Bajena)) 152 | - Add Loader classes [\#4](https://github.com/Bajena/ams_lazy_relationships/pull/4) ([Bajena](https://github.com/Bajena)) 153 | - Initial setup [\#3](https://github.com/Bajena/ams_lazy_relationships/pull/3) ([Bajena](https://github.com/Bajena)) 154 | 155 | 156 | 157 | \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* 158 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in ams_lazy_relationships.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | ams_lazy_relationships (0.4.0) 5 | active_model_serializers (>= 0.10.0.rc4) 6 | batch-loader 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actionpack (7.0.8) 12 | actionview (= 7.0.8) 13 | activesupport (= 7.0.8) 14 | rack (~> 2.0, >= 2.2.4) 15 | rack-test (>= 0.6.3) 16 | rails-dom-testing (~> 2.0) 17 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 18 | actionview (7.0.8) 19 | activesupport (= 7.0.8) 20 | builder (~> 3.1) 21 | erubi (~> 1.4) 22 | rails-dom-testing (~> 2.0) 23 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 24 | active_model_serializers (0.10.14) 25 | actionpack (>= 4.1) 26 | activemodel (>= 4.1) 27 | case_transform (>= 0.2) 28 | jsonapi-renderer (>= 0.1.1.beta1, < 0.3) 29 | activemodel (7.0.8) 30 | activesupport (= 7.0.8) 31 | activerecord (7.0.8) 32 | activemodel (= 7.0.8) 33 | activesupport (= 7.0.8) 34 | activesupport (7.0.8) 35 | concurrent-ruby (~> 1.0, >= 1.0.2) 36 | i18n (>= 1.6, < 2) 37 | minitest (>= 5.1) 38 | tzinfo (~> 2.0) 39 | addressable (2.8.5) 40 | public_suffix (>= 2.0.2, < 6.0) 41 | appraisal (2.5.0) 42 | bundler 43 | rake 44 | thor (>= 0.14.0) 45 | ast (2.4.2) 46 | async (2.6.5) 47 | console (~> 1.10) 48 | fiber-annotation 49 | io-event (~> 1.1) 50 | timers (~> 4.1) 51 | async-http (0.61.0) 52 | async (>= 1.25) 53 | async-io (>= 1.28) 54 | async-pool (>= 0.2) 55 | protocol-http (~> 0.25.0) 56 | protocol-http1 (~> 0.16.0) 57 | protocol-http2 (~> 0.15.0) 58 | traces (>= 0.10.0) 59 | async-http-faraday (0.12.0) 60 | async-http (~> 0.42) 61 | faraday 62 | async-io (1.38.0) 63 | async 64 | async-pool (0.4.0) 65 | async (>= 1.25) 66 | base64 (0.2.0) 67 | batch-loader (2.0.1) 68 | benchmark-memory (0.2.0) 69 | memory_profiler (~> 1) 70 | builder (3.2.4) 71 | case_transform (0.2) 72 | activesupport 73 | coderay (1.1.3) 74 | concurrent-ruby (1.2.2) 75 | console (1.23.2) 76 | fiber-annotation 77 | fiber-local 78 | crass (1.0.6) 79 | db-query-matchers (0.11.0) 80 | activesupport (>= 4.0, < 7.1) 81 | rspec (>= 3.0) 82 | diff-lcs (1.5.0) 83 | docile (1.4.0) 84 | erubi (1.12.0) 85 | faraday (2.7.11) 86 | base64 87 | faraday-net_http (>= 2.0, < 3.1) 88 | ruby2_keywords (>= 0.0.4) 89 | faraday-http-cache (2.5.0) 90 | faraday (>= 0.8) 91 | faraday-net_http (3.0.2) 92 | fiber-annotation (0.2.0) 93 | fiber-local (1.0.0) 94 | github_changelog_generator (1.16.4) 95 | activesupport 96 | async (>= 1.25.0) 97 | async-http-faraday 98 | faraday-http-cache 99 | multi_json 100 | octokit (~> 4.6) 101 | rainbow (>= 2.2.1) 102 | rake (>= 10.0) 103 | i18n (1.14.1) 104 | concurrent-ruby (~> 1.0) 105 | imagen (0.1.8) 106 | parser (>= 2.5, != 2.5.1.1) 107 | io-event (1.3.3) 108 | jaro_winkler (1.5.6) 109 | jsonapi-renderer (0.2.2) 110 | loofah (2.22.0) 111 | crass (~> 1.0.2) 112 | nokogiri (>= 1.12.0) 113 | memory_profiler (1.0.1) 114 | method_source (1.0.0) 115 | minitest (5.20.0) 116 | multi_json (1.15.0) 117 | nokogiri (1.15.5-x86_64-darwin) 118 | racc (~> 1.4) 119 | octokit (4.25.1) 120 | faraday (>= 1, < 3) 121 | sawyer (~> 0.9) 122 | parallel (1.23.0) 123 | parser (3.2.2.4) 124 | ast (~> 2.4.1) 125 | racc 126 | powerpack (0.1.3) 127 | protocol-hpack (1.4.2) 128 | protocol-http (0.25.0) 129 | protocol-http1 (0.16.0) 130 | protocol-http (~> 0.22) 131 | protocol-http2 (0.15.1) 132 | protocol-hpack (~> 1.4) 133 | protocol-http (~> 0.18) 134 | pry (0.14.2) 135 | coderay (~> 1.1) 136 | method_source (~> 1.0) 137 | pry-nav (1.0.0) 138 | pry (>= 0.9.10, < 0.15) 139 | public_suffix (5.0.4) 140 | racc (1.7.3) 141 | rack (2.2.8) 142 | rack-test (2.1.0) 143 | rack (>= 1.3) 144 | rails-dom-testing (2.2.0) 145 | activesupport (>= 5.0.0) 146 | minitest 147 | nokogiri (>= 1.6) 148 | rails-html-sanitizer (1.6.0) 149 | loofah (~> 2.21) 150 | nokogiri (~> 1.14) 151 | railties (7.0.8) 152 | actionpack (= 7.0.8) 153 | activesupport (= 7.0.8) 154 | method_source 155 | rake (>= 12.2) 156 | thor (~> 1.0) 157 | zeitwerk (~> 2.5) 158 | rainbow (3.1.1) 159 | rake (13.1.0) 160 | rspec (3.9.0) 161 | rspec-core (~> 3.9.0) 162 | rspec-expectations (~> 3.9.0) 163 | rspec-mocks (~> 3.9.0) 164 | rspec-core (3.9.3) 165 | rspec-support (~> 3.9.3) 166 | rspec-expectations (3.9.4) 167 | diff-lcs (>= 1.2.0, < 2.0) 168 | rspec-support (~> 3.9.0) 169 | rspec-mocks (3.9.1) 170 | diff-lcs (>= 1.2.0, < 2.0) 171 | rspec-support (~> 3.9.0) 172 | rspec-rails (3.9.1) 173 | actionpack (>= 3.0) 174 | activesupport (>= 3.0) 175 | railties (>= 3.0) 176 | rspec-core (~> 3.9.0) 177 | rspec-expectations (~> 3.9.0) 178 | rspec-mocks (~> 3.9.0) 179 | rspec-support (~> 3.9.0) 180 | rspec-support (3.9.4) 181 | rubocop (0.61.0) 182 | jaro_winkler (~> 1.5.1) 183 | parallel (~> 1.10) 184 | parser (>= 2.5, != 2.5.1.1) 185 | powerpack (~> 0.1) 186 | rainbow (>= 2.2.2, < 4.0) 187 | ruby-progressbar (~> 1.7) 188 | unicode-display_width (~> 1.4.0) 189 | rubocop-rspec (1.20.1) 190 | rubocop (>= 0.51.0) 191 | ruby-progressbar (1.13.0) 192 | ruby2_keywords (0.0.5) 193 | rugged (1.6.3) 194 | sawyer (0.9.2) 195 | addressable (>= 2.3.5) 196 | faraday (>= 0.17.3, < 3) 197 | simplecov (0.22.0) 198 | docile (~> 1.1) 199 | simplecov-html (~> 0.11) 200 | simplecov_json_formatter (~> 0.1) 201 | simplecov-html (0.12.3) 202 | simplecov-lcov (0.8.0) 203 | simplecov_json_formatter (0.1.4) 204 | sqlite3 (1.6.8-x86_64-darwin) 205 | thor (1.3.0) 206 | thread_safe (0.3.6) 207 | timers (4.3.5) 208 | traces (0.11.1) 209 | tzinfo (2.0.6) 210 | concurrent-ruby (~> 1.0) 211 | undercover (0.4.6) 212 | imagen (>= 0.1.8) 213 | rainbow (>= 2.1, < 4.0) 214 | rugged (>= 0.27, < 1.7) 215 | unicode-display_width (1.4.1) 216 | with_model (2.1.7) 217 | activerecord (>= 6.0) 218 | zeitwerk (2.6.12) 219 | 220 | PLATFORMS 221 | x86_64-darwin-21 222 | 223 | DEPENDENCIES 224 | activerecord 225 | ams_lazy_relationships! 226 | appraisal 227 | benchmark-memory (~> 0.1) 228 | db-query-matchers 229 | github_changelog_generator 230 | pry 231 | pry-nav 232 | rake (~> 13.0) 233 | rspec (~> 3.0) 234 | rspec-rails (~> 3.5) 235 | rubocop (= 0.61.0) 236 | rubocop-rspec (= 1.20.1) 237 | simplecov 238 | simplecov-lcov 239 | sqlite3 240 | thread_safe 241 | undercover (~> 0.4) 242 | with_model 243 | 244 | BUNDLED WITH 245 | 2.4.10 246 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jan Bajena 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. 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Jan Bajena 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/Bajena/ams_lazy_relationships.svg?branch=master)](https://travis-ci.com/Bajena/ams_lazy_relationships) 2 | [![Maintainability](https://api.codeclimate.com/v1/badges/c21b988e09db63396309/maintainability)](https://codeclimate.com/github/Bajena/ams_lazy_relationships/maintainability) 3 | [![Test Coverage](https://api.codeclimate.com/v1/badges/c21b988e09db63396309/test_coverage)](https://codeclimate.com/github/Bajena/ams_lazy_relationships/test_coverage) 4 | 5 | # AmsLazyRelationships 6 | 7 | #### What does the gem do? 8 | Eliminates N+1 queries problem in [Active Model Serializers gem](https://github.com/rails-api/active_model_serializers) thanks to batch loading provided by a great [BatchLoader gem](https://github.com/exAspArk/batch-loader). 9 | 10 | The gem provides a module which defines a set of methods useful for eliminating N+1 query problem 11 | during the serialization. Serializers will first prepare a tree of "promises" 12 | for every nested lazy relationship. The relationship promises will be 13 | evaluated only when they're requested. 14 | E.g. when including `blog_posts.user`: instead of loading a user for each blog post separately it'll gather the blog posts and load all their users at once when including the users in the response. 15 | 16 | #### How is it better than Rails' includes/joins methods? 17 | In many cases it's fine to use [`includes`](https://apidock.com/rails/ActiveRecord/QueryMethods/includes) method provided by Rails. 18 | There are a few problems with `includes` approach though: 19 | - It loads all the records provided in the arguments hash. Often you may not need all the nested records to serialize the data you want. `AmsLazyRelationships` will load only the data you need thanks to lazy evaluation. 20 | - When the app gets bigger and bigger you'd need to update all the `includes` statements across your app to prevent the N+1 queries problem which quickly becomes impossible. 21 | - It lets you remove N+1s even when not all relationships are ActiveRecord models (e.g. some records are stored in a MySQL DB and other models are stored in Cassandra) 22 | 23 | ## Installation 24 | 25 | 1. Add this line to your application's Gemfile: 26 | 27 | ```ruby 28 | gem "ams_lazy_relationships" 29 | ``` 30 | 31 | 2. Execute: 32 | ``` 33 | $ bundle 34 | ``` 35 | 36 | 3. Include `AmsLazyRelationships::Core` module in your base serializer 37 | 38 | ```ruby 39 | class BaseSerializer < ActiveModel::Serializer 40 | include AmsLazyRelationships::Core 41 | end 42 | ``` 43 | 44 | 4. **Important:** 45 | This gem uses `BatchLoader` heavily. I highly recommend to clear the batch loader's cache between HTTP requests. 46 | To do so add a following middleware: 47 | `config.middleware.use BatchLoader::Middleware` to your app's `application.rb`. 48 | 49 | For more info about the middleware check out BatchLoader gem docs: https://github.com/exAspArk/batch-loader#caching 50 | 51 | ## Usage 52 | Adding the `AmsLazyRelationships::Core` module lets you define lazy relationships in your serializers: 53 | ```ruby 54 | 55 | class UserSerializer < BaseSerializer 56 | # Short version - preloads a specified ActiveRecord relationship by default 57 | lazy_has_many :blog_posts 58 | 59 | # Works same as the previous one, but the loader option is specified explicitly 60 | lazy_has_many :blog_posts, 61 | serializer: BlogPostSerializer, 62 | loader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts) 63 | 64 | # The previous one is a shorthand for the following lines: 65 | lazy_relationship :blog_posts, loader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts) 66 | has_many :blog_posts, serializer: BlogPostSerializer do |serializer| 67 | # non-proc custom finder will work as well, but it can produce redundant sql 68 | # queries, please see [Example 2: Modifying the relationship before rendering](#example-2-modifying-the-relationship-before-rendering) 69 | -> { serializer.lazy_blog_posts } 70 | end 71 | 72 | lazy_has_one :poro_model, loader: AmsLazyRelationships::Loaders::Direct.new(:poro_model) { |object| PoroModel.new(object) } 73 | 74 | lazy_belongs_to :account, loader: AmsLazyRelationships::Loaders::SimpleBelongsTo.new("Account") 75 | 76 | lazy_has_many :comment, loader: AmsLazyRelationships::Loaders::SimpleHasMany.new("Comment", foreign_key: :user_id) 77 | end 78 | ``` 79 | 80 | As you may have already noticed the gem makes use of various loader classes. 81 | 82 | I've implemented the following ones for you: 83 | - `AmsLazyRelationships::Loaders::Association` - Batch loads a ActiveRecord association (has_one/has_many/has_many-through/belongs_to). This is a deafult loader in case you don't specify a `loader` option in your serializer's lazy relationship. 84 | E.g. in order to lazy load user's blog posts use a following loader: `AmsLazyRelationships::Loaders::Association.new("User", :blog_posts)`. 85 | 86 | - `AmsLazyRelationships::Loaders::SimpleBelongsTo` - Batch loads ActiveRecord models using a foreign key method called on a serialized object. E.g. `AmsLazyRelationships::Loaders::SimpleBelongsTo.new("Account")` called on users will gather their `account_id`s and fire one query to get all accounts at once instead of loading an account per user separately. 87 | This loader can be useful e.g. when the serialized object is not an ActiveRecord model. 88 | 89 | - `AmsLazyRelationships::Loaders::SimpleHasMany` - Batch loads ActiveRecord records belonging to given record by foreign key. E.g. `AmsLazyRelationships::Loaders::SimpleHasMany.new("BlogPosts", foreign_key: :user_id)` called on users will and fire one query to gather all blog posts for the users at once instead of loading an the blog posts per user separately. 90 | This loader can be useful e.g. when the serialized object is not an ActiveRecord model. 91 | 92 | - `AmsLazyRelationships::Loaders::Direct` - Lazy loads data in a "dumb" way - just executes the provided block when needed. Useful e.g. when the relationship is just a PORO which then in its own serializer needs to lazy load some relationships. 93 | You can use it like this: `AmsLazyRelationships::Loaders::Direct.new(:poro_model) { |object| PoroModel.new(object)`. 94 | 95 | The abovementioned loaders are mostly useful when using ActiveRecord, but there should be no problem building a new loader for different frameworks. 96 | If you're missing a loader you can create an issue or create your own loader taking the existing ones as an example. 97 | 98 | ### More examples 99 | Here are a few use cases for the lazy relationships. Hopefully they'll let you understand a bit more how the gem works. 100 | 101 | #### Example 1: Basic ActiveRecord relationships 102 | If the relationships in your serializers are plain old ActiveRecord relationships you're lucky, because ams_lazy_relationships by default assumes that the relationship is an ActiveRecord relationship, so you can use the simplest syntax. 103 | Imagine you have an endpoint that renders a list of blog posts and includes their comments. 104 | The N+1 prone way of defining the serializer would be: 105 | ```ruby 106 | class BlogPostSerializer < BaseSerializer 107 | has_many :comments 108 | end 109 | ``` 110 | 111 | To prevent loading comments using a separate DB query for each post just change it to: 112 | ```ruby 113 | class BlogPostSerializer < BaseSerializer 114 | lazy_has_many :comments 115 | end 116 | ``` 117 | 118 | #### Example 2: Modifying the relationship before rendering 119 | Sometimes it may happen that you need to process the relationship before rendering, e.g. decorate the records. In this case the gem provides a special method (in our case `lazy_comments`) for each defined relationship. Check out the example - we'll decorate every comment before serializing: 120 | 121 | ```ruby 122 | class BlogPostSerializer < BaseSerializer 123 | lazy_has_many :comments do |serializer| 124 | -> { serializer.lazy_comments.map(&:decorate) } 125 | end 126 | end 127 | ``` 128 | 129 | Despite the fact that non-block custom finder such as 130 | 131 | ```ruby 132 | class BlogPostSerializer < BaseSerializer 133 | lazy_has_many :comments do |serializer| 134 | serializer.lazy_comments.map(&:decorate) 135 | end 136 | end 137 | ``` 138 | 139 | will work still, it's better to implement it in a form of lambda, in order to avoid redundant SQL queries when `include_data` AMS setting appears to be `false`: 140 | 141 | ```ruby 142 | class BlogPostSerializer < BaseSerializer 143 | lazy_has_many :comments do |serializer| 144 | include_data :if_sideloaded 145 | -> { serializer.lazy_comments.map(&:decorate) } 146 | end 147 | end 148 | ``` 149 | 150 | Feel free to skip custom lazy finder for association if your goal is just to define `include_data` setting and/or to specify some links and metas: 151 | 152 | ```ruby 153 | class BlogPostSerializer < BaseSerializer 154 | lazy_has_many :comments do 155 | include_data :if_sideloaded 156 | link :self, 'a link' 157 | meta name: 'Dan Brown' 158 | end 159 | end 160 | ``` 161 | 162 | #### Example 3: Introducing loader classes 163 | Under the hood ams_lazy_relationships uses special loader classes to batch load the relationships. By default the gem uses serializer class names and relationship names to instantiate correct loaders, but it may happen that e.g. your serializer's class name doesn't match the model name (e.g. your model's name is `BlogPost` but the serializer's name is `PostSerializer`). 164 | 165 | In this case you can define the lazy relationship by passing a correct loader param: 166 | ```ruby 167 | class PostSerializer < BaseSerializer 168 | lazy_has_many :comments, serializer: CommentSerializer, 169 | loader: AmsLazyRelationships::Loaders::Association.new( 170 | "BlogPost", :comments 171 | ) 172 | end 173 | ``` 174 | 175 | #### Example 4: Non ActiveRecord -> ActiveRecord relationships 176 | This one is interesting. It may happen that the root record is not an ActiveRecord model (e.g. a Cequel model), however its relationship is an AR model. 177 | Imagine that `BlogPost` is not an AR model and `Comment` is a standard AR model. The lazy relationship would look like this: 178 | ```ruby 179 | class BlogPostSerializer < BaseSerializer 180 | lazy_has_many :comments, 181 | loader: AmsLazyRelationships::Loaders::SimpleHasMany.new( 182 | "Comment", foreign_key: :blog_post_id 183 | ) 184 | end 185 | ``` 186 | 187 | #### Example 5: Use lazy relationship without rendering it 188 | Sometimes you may just want to make use of lazy relationship without rendering the whole nested record.  189 | For example imagine that your `BlogPost` serializer is supposed to render `author_name` attribute. You can define the lazy relationship and just use it in other attribute evaluator: 190 | 191 | ```ruby 192 | class BlogPostSerializer < BaseSerializer 193 | lazy_relationship :author 194 | 195 | attribute :author_name do 196 | lazy_author.name 197 | end 198 | end 199 | ``` 200 | 201 | #### Example 6: Lazy dig through relationships 202 | In additional to previous example you may want to make use of nested lazy relationship without rendering of any nested record. 203 | There is an `lazy_dig` method to be used for that: 204 | 205 | ```ruby 206 | class AuthorSerializer < BaseSerializer 207 | lazy_relationship :address 208 | end 209 | 210 | class BlogPostSerializer < BaseSerializer 211 | lazy_relationship :author 212 | 213 | attribute :author_address do 214 | lazy_dig(:author, :address)&.full_address 215 | end 216 | end 217 | ``` 218 | 219 | ## Performance comparison with vanilla AMS 220 | 221 | In general the bigger and more complex your serialized records hierarchy is and the more latency you have in your DB the more you'll benefit from using this gem. 222 | Example results for average size records tree (10 blog posts -> 10 comments each -> 1 user per comment, performed on local in-memory SQLite DB) are: 223 | 224 | ### Time: 225 | 226 | ```bash 227 | # With lazy relationships: 0.860000 0.010000 0.870000 ( 0.870297) 228 | # Vanilla AMS: 1.050000 0.000000 1.050000 ( 1.059801) 229 | ``` 230 | 231 | This means your serializers should get **~13%** speed boost by introducing lazy relationships. 232 | 233 | ### Memory: 234 | 235 | ```bash 236 | # With lazy relationships: 237 | # 46.283M memsize ( 0.000 retained) 238 | # 506.696k objects ( 0.000 retained) 239 | # 50.000 strings ( 0.000 retained) 240 | # Vanilla AMS: 42.738M memsize ( 0.000 retained) 241 | # 545.266k objects ( 0.000 retained) 242 | # 50.000 strings ( 0.000 retained) 243 | ``` 244 | 245 | This means that serialization may consume **~5%** more memory. 246 | 247 | Detailed benchmark script & results can be found [here](/spec/benchmark_spec.rb). 248 | 249 | ## Development 250 | 251 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 252 | 253 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 254 | 255 | ## Contributing 256 | 257 | Bug reports and pull requests are welcome on GitHub at https://github.com/Bajena/ams_lazy_relationships. 258 | 259 | ## License 260 | 261 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 262 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release steps 2 | 1. Bump VERSION constant 3 | 2. Generate changelog and update 4 | ```shell 5 | CHANGELOG_GITHUB_TOKEN= bundle exec rake changelog 6 | ``` 7 | 3. Run `bundle` to regenerate Gemfile.lock 8 | 4. Commit & push a new tag 9 | 5. Build and push to rubygems 10 | ```shell 11 | gem build ams_lazy_relationships 12 | gem push ams_lazy_relationships-x.y.z.gem 13 | ``` 14 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "github_changelog_generator/task" 6 | require "ams_lazy_relationships/version" 7 | 8 | RSpec::Core::RakeTask.new(:spec) 9 | 10 | task default: :spec 11 | 12 | GitHubChangelogGenerator::RakeTask.new :changelog do |config| 13 | config.user = "Bajena" 14 | config.project = "ams_lazy_relationships" 15 | config.future_release = "v#{AmsLazyRelationships::VERSION}" 16 | end 17 | -------------------------------------------------------------------------------- /ams_lazy_relationships.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("../lib", __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "ams_lazy_relationships/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "ams_lazy_relationships" 9 | spec.version = AmsLazyRelationships::VERSION 10 | spec.authors = ["Jan Bajena"] 11 | 12 | spec.summary = "ActiveModel Serializers addon for eliminating N+1 queries problem from the serializers." 13 | spec.homepage = "https://github.com/Bajena/ams_lazy_relationships" 14 | spec.license = "MIT" 15 | 16 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 17 | # to allow pushing to a single host or delete this section to allow pushing to any host. 18 | if spec.respond_to?(:metadata) 19 | spec.metadata["homepage_uri"] = spec.homepage 20 | spec.metadata["source_code_uri"] = "https://github.com/Bajena/ams_lazy_relationships" 21 | spec.metadata["changelog_uri"] = "https://github.com/Bajena/ams_lazy_relationships/blob/master/CHANGELOG.md" 22 | else 23 | raise "RubyGems 2.0 or newer is required to protect against " \ 24 | "public gem pushes." 25 | end 26 | 27 | # Specify which files should be added to the gem when it is released. 28 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 29 | spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do 30 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 31 | end 32 | spec.bindir = "exe" 33 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 34 | spec.require_paths = ["lib"] 35 | 36 | spec.add_dependency "active_model_serializers", ">= 0.10.0.rc4" 37 | spec.add_dependency "batch-loader" 38 | 39 | spec.add_development_dependency "activerecord" 40 | # A Ruby library for testing against different versions of dependencies 41 | spec.add_development_dependency "appraisal" 42 | # Rspec matchers for SQL query counts 43 | spec.add_development_dependency "db-query-matchers" 44 | spec.add_development_dependency "github_changelog_generator" 45 | spec.add_development_dependency "pry" 46 | spec.add_development_dependency "pry-nav" 47 | spec.add_development_dependency "rake", "~> 13.0" 48 | spec.add_development_dependency "rspec", "~> 3.0" 49 | spec.add_development_dependency "rspec-rails", "~> 3.5" 50 | spec.add_development_dependency "rubocop", "= 0.61.0" 51 | spec.add_development_dependency "rubocop-rspec", "= 1.20.1" 52 | spec.add_development_dependency "simplecov" 53 | spec.add_development_dependency "simplecov-lcov" 54 | spec.add_development_dependency "sqlite3" 55 | # Detect untested code blocks in recent changes 56 | spec.add_development_dependency "undercover", "~> 0.4" 57 | # Dynamically build an Active Record model (with table) within a test context 58 | spec.add_development_dependency "with_model" 59 | 60 | # Implicit dependency of AMS - used to be a part of Rails 61 | spec.add_development_dependency "thread_safe" 62 | 63 | spec.add_development_dependency "benchmark-memory", "~> 0.1" 64 | end 65 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # frozen_string_literal: true 4 | 5 | require "bundler/setup" 6 | require "ams_lazy_relationships" 7 | 8 | # You can add fixtures and/or initialization code here to make experimenting 9 | # with your gem easier. You can also use a different console, if you like. 10 | 11 | # (If you use this, don't forget to add pry to your Gemfile!) 12 | # require "pry" 13 | # Pry.start 14 | 15 | require "irb" 16 | IRB.start(__FILE__) 17 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /gemfiles/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "1" 3 | -------------------------------------------------------------------------------- /gemfiles/ams_0.10.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "active_model_serializers", "0.10.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/ams_0.10.0.rc4.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "active_model_serializers", "0.10.0.rc4" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/ams_0.10.10.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "active_model_serializers", "0.10.10" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/ams_0.10.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "active_model_serializers", "0.10.2" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/ams_0.10.3.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "active_model_serializers", "0.10.3" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/ams_0.10.8.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "active_model_serializers", "0.10.8" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/batch_loader_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "batch-loader", "~> 1" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/batch_loader_2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "batch-loader", "~> 2" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails_6.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails_7.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "batch-loader" 4 | require "active_model_serializers" 5 | 6 | require "ams_lazy_relationships/version" 7 | require "ams_lazy_relationships/extensions" 8 | require "ams_lazy_relationships/loaders" 9 | require "ams_lazy_relationships/core" 10 | 11 | module AmsLazyRelationships 12 | end 13 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/core.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ams_lazy_relationships/core/lazy_relationship_method" 4 | require "ams_lazy_relationships/core/lazy_dig_method" 5 | require "ams_lazy_relationships/core/relationship_wrapper_methods" 6 | require "ams_lazy_relationships/core/evaluation" 7 | 8 | # This module defines a set of methods useful for eliminating N+1 query problem 9 | # during the serialization. Serializers will first prepare a tree of "promises" 10 | # for every nested lazy relationship. The relationship promises will be 11 | # evaluated only when they're requested. 12 | # E.g. when including `comments.user`: instead of loading a user for each comment 13 | # separately it'll gather the comments and load all their users at once 14 | # when including the users in the response. 15 | module AmsLazyRelationships::Core 16 | def self.ams_version 17 | @_ams_version ||= Gem::Version.new(ActiveModel::Serializer::VERSION) 18 | end 19 | 20 | def self.included(klass) 21 | klass.send :extend, ClassMethods 22 | klass.send :include, LazyDigMethod 23 | klass.send :prepend, Initializer 24 | 25 | klass.send(:define_relationship_wrapper_methods) 26 | end 27 | 28 | module ClassMethods 29 | include LazyRelationshipMethod 30 | include RelationshipWrapperMethods 31 | include Evaluation 32 | 33 | def inherited(subclass) 34 | super 35 | 36 | return unless @lazy_relationships 37 | 38 | subclass.instance_variable_set( 39 | :@lazy_relationships, @lazy_relationships.clone 40 | ) 41 | end 42 | 43 | private 44 | 45 | # lazy_relationships [Array] 46 | attr_reader :lazy_relationships 47 | end 48 | 49 | module Initializer 50 | def initialize(*) 51 | super 52 | 53 | self.class.send(:init_all_lazy_relationships, object) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/core/evaluation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AmsLazyRelationships::Core 4 | # Module responsible for lazy loading the relationships during the runtime 5 | module Evaluation 6 | private 7 | 8 | LAZY_NESTING_LEVELS = 3 9 | NESTING_START_LEVEL = 1 10 | 11 | # Loads the lazy relationship 12 | # 13 | # @param relation_name [Symbol] relation name to be loaded 14 | # @param object [Object] Lazy relationships will be loaded for this record. 15 | def load_lazy_relationship(relation_name, object) 16 | lrm = lazy_relationships[relation_name] 17 | unless lrm 18 | raise ArgumentError, "Undefined lazy '#{relation_name}' relationship for '#{name}' serializer" 19 | end 20 | 21 | # We need to evaluate the promise right before serializer tries 22 | # to touch it. Otherwise the various side effects can happen: 23 | # 1. AMS will attempt to serialize nil values with a specific V1 serializer 24 | # 2. `lazy_association ? 'exists' : 'missing'` expression will always 25 | # equal to 'exists' 26 | # 3. `lazy_association&.id` expression can raise NullPointer exception 27 | # 28 | # Calling `__sync` will evaluate the promise. 29 | init_lazy_relationship(lrm, object).__sync 30 | end 31 | 32 | # Recursively loads the tree of lazy relationships 33 | # The nesting is limited to 3 levels. 34 | # 35 | # @param object [Object] Lazy relationships will be loaded for this record. 36 | # @param level [Integer] Current nesting level 37 | def init_all_lazy_relationships(object, level = NESTING_START_LEVEL) 38 | return if level >= LAZY_NESTING_LEVELS 39 | return unless object 40 | 41 | return unless lazy_relationships 42 | 43 | lazy_relationships.each_value do |lrm| 44 | init_lazy_relationship(lrm, object, level) 45 | end 46 | end 47 | 48 | # @param lrm [LazyRelationshipMeta] relationship data 49 | # @param object [Object] Object to load the relationship for 50 | # @param level [Integer] Current nesting level 51 | def init_lazy_relationship(lrm, object, level = NESTING_START_LEVEL) 52 | load_for_object = if lrm.load_for.present? 53 | object.public_send(lrm.load_for) 54 | else 55 | object 56 | end 57 | 58 | lrm.loader.load(load_for_object) do |batch_records| 59 | deep_init_for_yielded_records( 60 | batch_records, 61 | lrm, 62 | level 63 | ) 64 | end 65 | end 66 | 67 | def deep_init_for_yielded_records(batch_records, lrm, level) 68 | # There'll be no more nesting if there's no 69 | # reflection for this relationship. We can skip deeper lazy loading. 70 | return unless lrm.reflection 71 | 72 | Array.wrap(batch_records).each do |r| 73 | deep_init_for_yielded_record(r, lrm, level) 74 | end 75 | end 76 | 77 | def deep_init_for_yielded_record(batch_record, lrm, level) 78 | serializer = lazy_serializer_for(batch_record, lrm: lrm) 79 | return unless serializer 80 | 81 | serializer.send(:init_all_lazy_relationships, batch_record, level + 1) 82 | end 83 | 84 | def lazy_serializer_for(object, lrm: nil, relation_name: nil) 85 | lrm ||= lazy_relationships[relation_name] 86 | return unless lrm&.reflection 87 | 88 | serializer_for(object, lrm.reflection.options) 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/core/lazy_dig_method.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AmsLazyRelationships::Core 4 | # Provides `lazy_dig` as an instance method for serializers, in order to make 5 | # possible to dig relationships in depth just like `Hash#dig` do, keeping the 6 | # laziness and N+1-free evaluation. 7 | module LazyDigMethod 8 | # @param relation_names [Array] the sequence of relation names 9 | # to dig through. 10 | # @return [ActiveRecord::Base, Array, nil] ActiveRecord 11 | # objects found by digging through the sequence of nested relationships. 12 | # Singular or plural nature of returned value depends from the 13 | # singular/plural nature of the chain of relation_names. 14 | # 15 | # @example 16 | # class AuthorSerializer < BaseSerializer 17 | # lazy_belongs_to :address 18 | # lazy_has_many :rewards 19 | # end 20 | # 21 | # class BlogPostSerializer < BaseSerializer 22 | # lazy_belongs_to :author 23 | # 24 | # attribute :author_address do 25 | # # returns single AR object or nil 26 | # lazy_dig(:author, :address)&.full_address 27 | # end 28 | # 29 | # attribute :author_rewards do 30 | # # returns an array of AR objects 31 | # lazy_dig(:author, :rewards).map(&:description) 32 | # end 33 | # end 34 | def lazy_dig(*relation_names) 35 | relationships = { 36 | multiple: false, 37 | data: [{ 38 | serializer: self.class, 39 | object: object 40 | }] 41 | } 42 | 43 | relation_names.each do |relation_name| 44 | lazy_dig_relationship!(relation_name, relationships) 45 | end 46 | 47 | objects = relationships[:data].map { |r| r[:object] } 48 | 49 | relationships[:multiple] ? objects : objects.first 50 | end 51 | 52 | private 53 | 54 | def lazy_dig_relationship!(relation_name, relationships) 55 | relationships[:data].map! do |data| 56 | serializer = data[:serializer] 57 | object = data[:object] 58 | 59 | next_objects = lazy_dig_next_objects!(relation_name, serializer, object) 60 | next unless next_objects 61 | 62 | relationships[:multiple] ||= next_objects.respond_to?(:to_ary) 63 | 64 | lazy_dig_next_relationships!(relation_name, serializer, next_objects) 65 | end 66 | 67 | relationships[:data].flatten! 68 | relationships[:data].compact! 69 | end 70 | 71 | def lazy_dig_next_objects!(relation_name, serializer, object) 72 | serializer&.send( 73 | :load_lazy_relationship, 74 | relation_name, 75 | object 76 | ) 77 | end 78 | 79 | def lazy_dig_next_relationships!(relation_name, serializer, next_objects) 80 | Array.wrap(next_objects).map do |next_object| 81 | next_serializer = serializer.send( 82 | :lazy_serializer_for, 83 | next_object, 84 | relation_name: relation_name 85 | ) 86 | 87 | { 88 | serializer: next_serializer, 89 | object: next_object 90 | } 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/core/lazy_relationship_meta.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AmsLazyRelationships::Core 4 | # Internal helper class for keeping relationship details 5 | class LazyRelationshipMeta 6 | # @param name [String/Symbol] lazy relationship name. Can be different than the relationship name 7 | # @param loader [Object] lazy loader for the relationship. Has to respond to `load(record, &block)`. 8 | # @param reflection [Object] AMS relationship meta. Keeps data like the serializer for the relationship. 9 | # This data structure differs for various AMS versions. 10 | # @param load_for [Symbol] Optionally you can delegate the loading to 11 | # a method defined by `load_for` symbol. 12 | # It is useful e.g. when the loaded object is a decorated object and the 13 | # real AR model is accessible by calling the decorator's method. 14 | def initialize(name:, loader:, reflection:, load_for: nil) 15 | @name = name.to_sym 16 | @loader = loader 17 | @reflection = reflection 18 | @load_for = load_for 19 | end 20 | 21 | attr_reader :name, :loader, :reflection, :load_for 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/core/lazy_relationship_method.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ams_lazy_relationships/core/lazy_relationship_meta" 4 | 5 | module AmsLazyRelationships::Core 6 | module LazyRelationshipMethod 7 | # This method defines a new lazy relationship on the serializer and a method 8 | # with `lazy_` prefix. 9 | # 10 | # @param name [Symbol] The name of the lazy relationship. It'll be used 11 | # to define lazy_ method. 12 | # 13 | # @param loader [Object] An object responding to `load(record)` method. 14 | # By default the AR association loader is used. 15 | # The loader should either lazy load (e.g. use BatchLoader) the data or 16 | # perform a very light action, because it might be called more than once 17 | # when serializing the data. 18 | # 19 | # @param load_for [Symbol] Optionally you can delegate the loading to 20 | # a method defined by `load_for` symbol. 21 | # It is useful e.g. when the loaded object is a decorated object and the 22 | # real AR model is accessible by calling the decorator's method. 23 | def lazy_relationship(name, loader: nil, load_for: nil) 24 | @lazy_relationships ||= {} 25 | 26 | name = name.to_sym 27 | 28 | loader ||= begin 29 | current_model_class = self.name.demodulize.gsub("Serializer", "") 30 | AmsLazyRelationships::Loaders::Association.new(current_model_class, name) 31 | end 32 | 33 | lrm = LazyRelationshipMeta.new( 34 | name: name, 35 | loader: loader, 36 | reflection: find_reflection(name), 37 | load_for: load_for 38 | ) 39 | @lazy_relationships[name] = lrm 40 | 41 | define_method :"lazy_#{name}" do 42 | self.class.send(:load_lazy_relationship, name, object) 43 | end 44 | end 45 | 46 | private 47 | 48 | def find_reflection(name) 49 | version = AmsLazyRelationships::Core.ams_version 50 | 51 | # In 0.10.3 this private API has changed again 52 | return _reflections[name] if version >= Gem::Version.new("0.10.3") 53 | 54 | # In 0.10.0.rc2 this private API has changed 55 | return _reflections.find { |r| r.name.to_sym == name } if version >= Gem::Version.new("0.10.0.rc2") 56 | 57 | _associations[name] 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/core/relationship_wrapper_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AmsLazyRelationships::Core 4 | # Defines convenience methods - wraps `has_many/belongs_to/has_one` and defines a 5 | # lazy_has_many, lazy_has_one and lazy_belongs_to. 6 | # 7 | # Calling lazy_has_one in your serializer class will: 8 | # - define a lazy_relationship 9 | # - define a has_one relationship 10 | # 11 | # You can optionally pass a block, just like in standard AMS relationships 12 | # If block is not present it'll call `lazy_xxx` method where `xxx` is the 13 | # name of the relationship. 14 | module RelationshipWrapperMethods 15 | private 16 | 17 | def define_relationship_wrapper_methods 18 | %i[has_many belongs_to has_one].each do |relationship_type| 19 | define_singleton_method( 20 | "lazy_#{relationship_type}" 21 | ) do |relationship_name, options = {}, &block| 22 | define_lazy_association(relationship_type, relationship_name, options, block) 23 | end 24 | end 25 | end 26 | 27 | def define_lazy_association(type, name, options, block) 28 | lazy_relationship_option_keys = %i[load_for loader] 29 | 30 | real_relationship_options = options.except(*lazy_relationship_option_keys) 31 | 32 | public_send(type, name.to_sym, real_relationship_options) do |serializer| 33 | block_value = instance_exec(serializer, &block) if block 34 | 35 | if block && block_value != :nil 36 | # respect the custom finder for lazy association 37 | # @see https://github.com/rails-api/active_model_serializers/blob/v0.10.10/lib/active_model/serializer/reflection.rb#L165-L168 38 | block_value 39 | else 40 | # provide default lazy association finder in a form of lambda, 41 | # in order to play nice with possible `include_data` setting. 42 | # @see lib/ams_lazy_relationships/extensions/reflection.rb 43 | serializer.method("lazy_#{name}") 44 | end 45 | end 46 | 47 | lazy_relationship(name, **options.slice(*lazy_relationship_option_keys)) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/extensions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ams_lazy_relationships/extensions/reflection" 4 | 5 | module AmsLazyRelationships 6 | module Extensions 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/extensions/reflection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # There is a general problem inside AMS related to custom association finder 4 | # combined with `include_data` setting: 5 | # 6 | # class BlogPostSerializer < BaseSerializer 7 | # belongs_to :category do 8 | # include_data :if_sideloaded 9 | # object.categories.last 10 | # end 11 | # end 12 | # 13 | # The problem is that `belongs_to` block will be fully evaluated each time for 14 | # each object, and only after that AMS is able to take into account 15 | # `include_data` mode - 16 | # https://github.com/rails-api/active_model_serializers/blob/v0.10.10/lib/active_model/serializer/reflection.rb#L162-L163 17 | # 18 | # def value(serializer, include_slice) 19 | # # ... 20 | # block_value = instance_exec(serializer, &block) if block 21 | # return unless include_data?(include_slice) 22 | # # ... 23 | # end 24 | # 25 | # That causing redundant (and so huge potentially!) SQL queries and AR objects 26 | # allocation when `include_data` appears to be `false` but `belongs_to` block 27 | # defines instant (not a kind of AR::Relation) custom association finder. 28 | # 29 | # Described problem is a very specific use case for pure AMS applications. 30 | # The bad news is that `ams_lazy_relationships` always utilizes the 31 | # association block - 32 | # https://github.com/Bajena/ams_lazy_relationships/blob/v0.2.0/lib/ams_lazy_relationships/core/relationship_wrapper_methods.rb#L32-L36 33 | # 34 | # def define_lazy_association(type, name, options, block) 35 | # #... 36 | # block ||= lambda do |serializer| 37 | # serializer.public_send("lazy_#{name}") 38 | # end 39 | # 40 | # public_send(type, name.to_sym, real_relationship_options, &block) 41 | # #... 42 | # end 43 | # 44 | # This way we break `include_data` optimizations for the host application. 45 | # 46 | # In order to overcome that we are forced to monkey-patch 47 | # `AmsLazyRelationships::Extensions::Reflection#value` method and make it to be 48 | # ready for Proc returned by association block. This way we will use a kind of 49 | # 50 | # block ||= lambda do |serializer| 51 | # -> { serializer.public_send("lazy_#{name}") } 52 | # end 53 | # 54 | # as association block, then AMS will evaluate it, get the value of `include_data` 55 | # setting, make a decision do we need to continue with that association, if so - 56 | # will finally evaluate the proc with lazy relationship inside it. 57 | 58 | module AmsLazyRelationships 59 | module Extensions 60 | module Reflection 61 | def value(*) 62 | case (block_value = super) 63 | when Proc, Method then block_value.call 64 | else block_value 65 | end 66 | end 67 | end 68 | end 69 | end 70 | 71 | ::ActiveModel::Serializer::Reflection.prepend AmsLazyRelationships::Extensions::Reflection 72 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/loaders.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ams_lazy_relationships/loaders/association" 4 | require "ams_lazy_relationships/loaders/direct" 5 | require "ams_lazy_relationships/loaders/simple_belongs_to" 6 | require "ams_lazy_relationships/loaders/simple_has_many" 7 | 8 | module AmsLazyRelationships 9 | module Loaders 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/loaders/association.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ams_lazy_relationships/loaders/base" 4 | 5 | module AmsLazyRelationships 6 | module Loaders 7 | # Lazy loads (has_one/has_many/has_many_through/belongs_to) ActiveRecord 8 | # associations for ActiveRecord models 9 | class Association < Base 10 | # @param model_class_name [String] The name of AR class for which the 11 | # associations are loaded. E.g. When loading comment.blog_post 12 | # it'd be "BlogPost". 13 | # @param association_name [Symbol] The name of association being loaded 14 | # E.g. When loading comment.blog_post it'd be :blog_post 15 | def initialize(model_class_name, association_name) 16 | @model_class_name = model_class_name 17 | @association_name = association_name 18 | end 19 | 20 | private 21 | 22 | attr_reader :model_class_name, :association_name 23 | 24 | def load_data(records, loader) 25 | preload(records) 26 | 27 | data = [] 28 | records.each do |r| 29 | value = r.public_send(association_name) 30 | data << value 31 | loader.call(r, value) 32 | end 33 | 34 | data = data.flatten.compact.uniq 35 | end 36 | 37 | def batch_key(_) 38 | @batch_key ||= "#{model_class_name}/#{association_name}" 39 | end 40 | 41 | def preload(records) 42 | if ::ActiveRecord::VERSION::MAJOR >= 7 43 | ::ActiveRecord::Associations::Preloader.new( 44 | records: records_to_preload(records), 45 | associations: association_name 46 | ).call 47 | else 48 | ::ActiveRecord::Associations::Preloader.new.preload( 49 | records_to_preload(records), association_name 50 | ) 51 | end 52 | end 53 | 54 | def records_to_preload(records) 55 | # It may happen that same record comes here twice (e.g. wrapped 56 | # in a decorator and non-wrapped). In this case Associations::Preloader 57 | # stores duplicated records in has_many relationships for some reason. 58 | # Calling uniq(&:id) solves the problem. 59 | # 60 | # One more case when duplicated records appear in has_many relationships 61 | # is the recent assignation to `accept_nested_attributes_for` setter. 62 | # ActiveRecord will not mark the association as `loaded` but in same 63 | # time will keep internal representation of the nested records created 64 | # by `accept_nested_attributes_for`. Then Associations::Preloader is 65 | # going to merge internal state of associated records with the same 66 | # records recently stored in DB. `r.association(association_name).reset` 67 | # effectively fixes that. 68 | records. 69 | uniq(&:id). 70 | reject { |r| r.association(association_name).loaded? }. 71 | each { |r| r.association(association_name).reset } 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/loaders/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AmsLazyRelationships 4 | module Loaders 5 | # A base class for all the loaders. A correctly defined loader requires 6 | # the `load_data` and `batch_key` methods. 7 | class Base 8 | # Lazy loads and yields the data when evaluating 9 | # @param record [Object] an object for which we're loading the data 10 | # @param block [Proc] a block to execute when data is evaluated. 11 | # Loaded data is yielded as a block argument. 12 | def load(record, &block) 13 | BatchLoader.for(record).batch( 14 | key: batch_key(record), 15 | # Replacing methods can be costly, especially on objects with lots 16 | # of methods (like AR methods). Let's disable it. 17 | # More info: 18 | # https://github.com/exAspArk/batch-loader/tree/v1.4.1#replacing-methods 19 | replace_methods: false 20 | ) do |records, loader| 21 | data = load_data(records, loader) 22 | 23 | block&.call(data) 24 | end 25 | end 26 | 27 | protected 28 | 29 | # Loads required data for all records gathered by the batch loader. 30 | # Assigns data to records by calling the `loader` lambda. 31 | # @param records [Array] Array of all gathered records. 32 | # @param loader [Proc] Proc used for assigning the batch loaded data to 33 | # records. First argument is the record and the second is the data 34 | # loaded for it. 35 | # @returns [Array] Array of loaded objects 36 | def load_data(_records, _loader) 37 | raise "Implement in child" 38 | end 39 | 40 | # Computes a batching key based on currently evaluated record 41 | # @param record [Object] 42 | # @returns [String] Batching key 43 | def batch_key(_record) 44 | raise "Implement in child" 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/loaders/direct.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ams_lazy_relationships/loaders/base" 4 | 5 | module AmsLazyRelationships 6 | module Loaders 7 | # Lazy loads data in a "dumb" way - just executes the provided block when needed 8 | class Direct < Base 9 | # @param relationship_name [Symbol] used for building cache key. Also if the 10 | # `load_block` param is `nil` the loader will just call `relationship_name` 11 | # method on the record being processed. 12 | # @param load_block [Proc] If present the loader will call this block when 13 | # evaluating the data. 14 | def initialize(relationship_name, &load_block) 15 | @relationship_name = relationship_name 16 | @load_block = load_block 17 | end 18 | 19 | private 20 | 21 | attr_reader :relationship_name, :load_block 22 | 23 | def load_data(records, loader) 24 | data = [] 25 | records.each do |r| 26 | value = calculate_value(r) 27 | data << value 28 | loader.call(r, value) 29 | end 30 | 31 | data = data.flatten.compact.uniq 32 | end 33 | 34 | def batch_key(record) 35 | "#{record.class}/#{relationship_name}" 36 | end 37 | 38 | def calculate_value(record) 39 | return record.public_send(relationship_name) unless load_block 40 | 41 | load_block.call(record) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/loaders/simple_belongs_to.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ams_lazy_relationships/loaders/base" 4 | 5 | module AmsLazyRelationships 6 | module Loaders 7 | # Batch loads parent ActiveRecord records for given record by foreign key. 8 | # Useful when the relationship is not a standard ActiveRecord relationship. 9 | class SimpleBelongsTo < Base 10 | # @param association_class_name [String] The name of AR class being the parent 11 | # record of the records being loaded. E.g. When loading comment.blog_post 12 | # it'd be "BlogPost". 13 | # @param foreign_key [Symbol/String] Name of the foreign key column 14 | # E.g. When loading comment.blog_post it'd be "blog_post_id 15 | def initialize( 16 | association_class_name, 17 | foreign_key: "#{association_class_name.underscore}_id" 18 | ) 19 | @association_class_name = association_class_name 20 | @foreign_key = foreign_key.to_sym 21 | end 22 | 23 | private 24 | 25 | attr_reader :association_class_name, :foreign_key 26 | 27 | def load_data(records, loader) 28 | data_ids = records.map(&foreign_key).compact.uniq 29 | data = if data_ids.present? 30 | association_class_name.constantize.where(id: data_ids) 31 | else 32 | [] 33 | end 34 | 35 | resolve(records, data, loader) 36 | 37 | data 38 | end 39 | 40 | def resolve(records, data, loader) 41 | data = data.index_by { |d| d.id.to_s } 42 | records.each do |r| 43 | fk_value = r.public_send(foreign_key).to_s 44 | loaded_item = data[fk_value] 45 | loader.call(r, loaded_item) 46 | end 47 | end 48 | 49 | def batch_key(record) 50 | "#{record.class}/#{association_class_name}" 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/loaders/simple_has_many.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ams_lazy_relationships/loaders/base" 4 | 5 | module AmsLazyRelationships 6 | module Loaders 7 | # Batch loads ActiveRecord records belonging to given record by foreign key. 8 | # Useful when the relationship is not a standard ActiveRecord relationship. 9 | class SimpleHasMany < Base 10 | # @param association_class_name [String] Name of the ActiveRecord class 11 | # e.g. in case when loading blog_post.comments it'd be "Comment" 12 | # @param foreign_key [Symbol] association's foreign key. 13 | # e.g. in case when loading blog_post.comments it'd be :blog_post_id 14 | def initialize(association_class_name, foreign_key:) 15 | @association_class_name = association_class_name 16 | @foreign_key = foreign_key.to_sym 17 | end 18 | 19 | private 20 | 21 | attr_reader :association_class_name, :foreign_key 22 | 23 | def load_data(records, loader) 24 | # Some records use UUID class as id - it's safer to cast them to strings 25 | record_ids = records.map { |r| r.id.to_s } 26 | association_class_name.constantize.where( 27 | foreign_key => record_ids 28 | ).tap do |data| 29 | resolve(records, data, loader) 30 | end 31 | end 32 | 33 | def resolve(records, data, loader) 34 | data = data.group_by { |d| d.public_send(foreign_key).to_s } 35 | 36 | records.each do |r| 37 | loader.call(r, data[r.id.to_s] || []) 38 | end 39 | end 40 | 41 | def batch_key(record) 42 | "#{record.class}/#{association_class_name}" 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/ams_lazy_relationships/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AmsLazyRelationships 4 | VERSION = "0.4.0" 5 | end 6 | -------------------------------------------------------------------------------- /spec/ams_lazy_relationships_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe AmsLazyRelationships do 4 | it "has a version number" do 5 | expect(AmsLazyRelationships::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/benchmark_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "benchmark" 5 | require "benchmark/memory" 6 | 7 | RSpec.describe AmsLazyRelationships::Core do 8 | extend WithArModels 9 | 10 | with_ar_models 11 | 12 | class NonLazyUserSerializer < ActiveModel::Serializer 13 | end 14 | 15 | class NonLazyCommentSerializer < ActiveModel::Serializer 16 | belongs_to :user, serializer: NonLazyUserSerializer 17 | end 18 | 19 | class NonLazyBlogPostSerializer < ActiveModel::Serializer 20 | has_many :comments, serializer: NonLazyCommentSerializer 21 | end 22 | 23 | class BaseProfSerializer < ActiveModel::Serializer 24 | include AmsLazyRelationships::Core 25 | end 26 | 27 | class UserSerializer < BaseProfSerializer 28 | end 29 | 30 | class CommentSerializer < BaseProfSerializer 31 | lazy_belongs_to :user, serializer: UserSerializer 32 | end 33 | 34 | class BlogPostSerializer < BaseProfSerializer 35 | lazy_has_many :comments, serializer: CommentSerializer 36 | end 37 | 38 | def serialize(serializer) 39 | ActiveModelSerializers::SerializableResource.new( 40 | BlogPost.where(id: blog_posts.map(&:id)), 41 | each_serializer: serializer, 42 | adapter: :json_api, 43 | include: includes 44 | ).as_json.tap do 45 | BatchLoader::Executor.clear_current 46 | end 47 | end 48 | 49 | let(:includes) do 50 | ["comments.user"] 51 | end 52 | 53 | attr_reader :blog_posts 54 | 55 | def create_post(comment_count) 56 | BlogPost.create!.tap do |bp| 57 | create_comments(bp, comment_count) 58 | end 59 | end 60 | 61 | def create_comments(post, comment_count) 62 | (1..comment_count).map do 63 | Comment.create!(blog_post_id: post.id, user_id: User.create!.id) 64 | end 65 | end 66 | 67 | before do 68 | ActiveModelSerializers.logger = Logger.new(nil) 69 | end 70 | 71 | def benchmark(post_count, comment_count) 72 | @blog_posts = (1..post_count).map { create_post(comment_count) } 73 | n = 10 74 | 75 | puts "Experiment (posts: #{post_count}, comments per post: #{comment_count})" 76 | puts "Time:" 77 | 78 | Benchmark.benchmark("", 25) do |benchmark| 79 | benchmark.report("With lazy relationships:") do 80 | n.times { serialize(BlogPostSerializer) } 81 | end 82 | 83 | benchmark.report("Vanilla AMS:") do 84 | n.times { serialize(NonLazyBlogPostSerializer) } 85 | end 86 | end 87 | 88 | puts "Memory:" 89 | 90 | Benchmark.memory do |bmem| 91 | bmem.report("With lazy relationships:") do 92 | n.times { serialize(BlogPostSerializer) } 93 | end 94 | 95 | bmem.report("Vanilla AMS:") do 96 | n.times { serialize(NonLazyBlogPostSerializer) } 97 | end 98 | end 99 | end 100 | 101 | xit "Performance benchmark" do 102 | benchmark(1, 1) 103 | benchmark(1, 10) 104 | benchmark(10, 1) 105 | benchmark(10, 10) 106 | benchmark(100, 10) 107 | benchmark(10, 100) 108 | benchmark(100, 100) 109 | end 110 | end 111 | 112 | # Experiment (posts: 1, comments per post: 1) 113 | # Time: 114 | # With lazy relationships: 0.050000 0.010000 0.060000 ( 0.065191) 115 | # Vanilla AMS: 0.030000 0.000000 0.030000 ( 0.029424) 116 | # Memory: 117 | # Calculating ------------------------------------- 118 | # With lazy relationships: 119 | # 1.130M memsize ( 0.000 retained) 120 | # 13.956k objects ( 0.000 retained) 121 | # 50.000 strings ( 0.000 retained) 122 | # Vanilla AMS: 887.690k memsize ( 0.000 retained) 123 | # 11.816k objects ( 0.000 retained) 124 | # 50.000 strings ( 0.000 retained) 125 | # Experiment (posts: 1, comments per post: 10) 126 | # Time: 127 | # With lazy relationships: 0.110000 0.000000 0.110000 ( 0.105953) 128 | # Vanilla AMS: 0.100000 0.000000 0.100000 ( 0.105560) 129 | # Memory: 130 | # Calculating ------------------------------------- 131 | # With lazy relationships: 132 | # 4.962M memsize ( 0.000 retained) 133 | # 55.416k objects ( 0.000 retained) 134 | # 50.000 strings ( 0.000 retained) 135 | # Vanilla AMS: 4.340M memsize ( 0.000 retained) 136 | # 55.016k objects ( 0.000 retained) 137 | # 50.000 strings ( 0.000 retained) 138 | # Experiment (posts: 10, comments per post: 1) 139 | # Time: 140 | # With lazy relationships: 0.170000 0.000000 0.170000 ( 0.168016) 141 | # Vanilla AMS: 0.170000 0.010000 0.180000 ( 0.175634) 142 | # Memory: 143 | # Calculating ------------------------------------- 144 | # With lazy relationships: 145 | # 8.154M memsize ( 0.000 retained) 146 | # 93.596k objects ( 0.000 retained) 147 | # 50.000 strings ( 0.000 retained) 148 | # Vanilla AMS: 7.697M memsize ( 0.000 retained) 149 | # 102.466k objects ( 0.000 retained) 150 | # 50.000 strings ( 0.000 retained) 151 | # Experiment (posts: 10, comments per post: 10) 152 | # Time: 153 | # With lazy relationships: 0.860000 0.010000 0.870000 ( 0.870297) 154 | # Vanilla AMS: 1.050000 0.000000 1.050000 ( 1.059801) 155 | # Memory: 156 | # Calculating ------------------------------------- 157 | # With lazy relationships: 158 | # 46.283M memsize ( 0.000 retained) 159 | # 506.696k objects ( 0.000 retained) 160 | # 50.000 strings ( 0.000 retained) 161 | # Vanilla AMS: 42.738M memsize ( 0.000 retained) 162 | # 545.266k objects ( 0.000 retained) 163 | # 50.000 strings ( 0.000 retained) 164 | # Experiment (posts: 100, comments per post: 10) 165 | # Time: 166 | # With lazy relationships: 10.210000 0.060000 10.270000 ( 10.298336) 167 | # Vanilla AMS: 12.270000 0.050000 12.320000 ( 12.358776) 168 | # Memory: 169 | # Calculating ------------------------------------- 170 | # With lazy relationships: 171 | # 459.252M memsize ( 40.000 retained) 172 | # 5.016M objects ( 1.000 retained) 173 | # 50.000 strings ( 0.000 retained) 174 | # Vanilla AMS: 425.823M memsize ( 40.000 retained) 175 | # 5.432M objects ( 1.000 retained) 176 | # 50.000 strings ( 0.000 retained) 177 | # Experiment (posts: 10, comments per post: 100) 178 | # Time: 179 | # With lazy relationships: 9.820000 0.260000 10.080000 ( 10.080571) 180 | # Vanilla AMS: 9.260000 0.160000 9.420000 ( 9.422104) 181 | # Memory: 182 | # Calculating ------------------------------------- 183 | # With lazy relationships: 184 | # 427.364M memsize ( 0.000 retained) 185 | # 4.638M objects ( 0.000 retained) 186 | # 50.000 strings ( 0.000 retained) 187 | # Vanilla AMS: 392.713M memsize ( 0.000 retained) 188 | # 4.973M objects ( 0.000 retained) 189 | # 50.000 strings ( 0.000 retained) 190 | # Experiment (posts: 100, comments per post: 100) 191 | # Time: 192 | # With lazy relationships: 86.900000 0.960000 87.860000 ( 87.898241) 193 | # Vanilla AMS: 96.620000 0.100000 96.720000 ( 96.769373) 194 | -------------------------------------------------------------------------------- /spec/core_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | AMS_VERSION = Gem::Version.new(ActiveModel::Serializer::VERSION) 6 | 7 | RSpec.describe AmsLazyRelationships::Core do 8 | extend WithArModels 9 | 10 | with_ar_models 11 | 12 | class BaseTestSerializer < ActiveModel::Serializer 13 | include AmsLazyRelationships::Core 14 | 15 | attributes :id 16 | end 17 | 18 | let(:user) { User.create! } 19 | let(:level0_record) do 20 | user 21 | end 22 | let(:serializer) do 23 | level0_serializer_class.new(level0_record) 24 | end 25 | let(:includes) do 26 | [] 27 | end 28 | 29 | let(:json_api_adapter_class) do 30 | return "ActiveModelSerializers::Adapter::JsonApi".constantize if AMS_VERSION >= Gem::Version.new("0.10.0.rc5") 31 | "ActiveModel::Serializer::Adapter::JsonApi".constantize 32 | end 33 | 34 | let(:json_adapter_class) do 35 | return "ActiveModelSerializers::Adapter::Json".constantize if AMS_VERSION >= Gem::Version.new("0.10.0.rc5") 36 | return "ActiveModel::Serializer::Adapter::Json".constantize 37 | end 38 | 39 | let(:adapter_class) do 40 | json_adapter_class 41 | end 42 | 43 | let(:json) do 44 | adapter_class.new( 45 | serializer, include: includes 46 | ).as_json 47 | end 48 | let!(:level1_records) do 49 | (0..2).map do |i| 50 | BlogPost.create!(user_id: user.id, category_id: level2_records[i].id) 51 | end 52 | end 53 | let!(:level2_records) do 54 | (0..2).map do 55 | Category.create! 56 | end 57 | end 58 | 59 | let!(:level3_records) do 60 | (0..2).map do |i| 61 | CategoryFollower.create!(category_id: level2_records[i].id) 62 | end 63 | end 64 | 65 | let(:level0_serializer_class) do 66 | class Level3Serializer0 < BaseTestSerializer 67 | end 68 | 69 | class Level2Serializer0 < BaseTestSerializer 70 | has_many :level3, serializer: Level3Serializer0 do |s| 71 | s.lazy_level3 72 | end 73 | lazy_relationship :level3, loader: AmsLazyRelationships::Loaders::SimpleHasMany.new( 74 | "CategoryFollower", foreign_key: :category_id 75 | ) 76 | end 77 | 78 | class Level1Serializer0 < BaseTestSerializer 79 | lazy_has_one :level2, serializer: Level2Serializer0 80 | lazy_relationship :level2, loader: AmsLazyRelationships::Loaders::SimpleBelongsTo.new( 81 | "Category" 82 | ) 83 | end 84 | 85 | class Level0Serializer0 < BaseTestSerializer 86 | has_many :level1, serializer: Level1Serializer0 do |s| 87 | s.lazy_level1 88 | end 89 | lazy_relationship :level1, loader: AmsLazyRelationships::Loaders::Association.new( 90 | "User", :blog_posts 91 | ) 92 | end 93 | 94 | Level0Serializer0 95 | end 96 | 97 | it "defines a lazy_ instance method" do 98 | expect(serializer).to respond_to(:lazy_level1) 99 | end 100 | 101 | describe "json_api" do 102 | let(:adapter_class) do 103 | json_api_adapter_class 104 | end 105 | 106 | let(:included_level1_ids) do 107 | json[:included].map { |i| i[:id] } 108 | end 109 | let(:relationship_level2_ids) do 110 | json[:included].map do |i| 111 | i.dig(:relationships, :level2, :data, :id) 112 | end 113 | end 114 | let(:relationship_level1_ids) do 115 | json.dig(:data, :relationships, :level1, :data).map do |i| 116 | i[:id] 117 | end 118 | end 119 | 120 | context "0 level nesting requested" do 121 | it "lazy evaluates up to level 1" do 122 | expect do 123 | expect do 124 | json 125 | end.to make_database_queries(count: 1, matching: "blog_posts") # Needed to render ids 126 | end.not_to make_database_queries(matching: "categories") 127 | end 128 | 129 | it "renders correct results" do 130 | expect(relationship_level1_ids).to match_array(level1_records.map(&:id)) 131 | end 132 | 133 | context "association populated by accepts_nested_attributes_for" do 134 | let(:user) { User.create!(blog_posts_attributes: [{title: 'Foo'}]) } 135 | let(:level1_records) { [] } 136 | 137 | it "avoids the duplication of associated records" do 138 | expect(relationship_level1_ids.size).to eq(1) 139 | end 140 | end 141 | end 142 | 143 | context "1 level nesting requested" do 144 | let(:includes) { %w(level1) } 145 | 146 | it "lazy evaluates up to level 1" do 147 | expect do 148 | expect do 149 | expect do 150 | json 151 | end.to make_database_queries(count: 1, matching: "blog_posts") 152 | end.to make_database_queries(count: 1, matching: "categories") # Needed to render ids 153 | end.not_to make_database_queries(matching: "category_followers") 154 | end 155 | 156 | it "renders correct results" do 157 | expect(included_level1_ids).to match_array(level1_records.map(&:id)) 158 | expect(relationship_level2_ids).to match_array(level2_records.map(&:id)) 159 | end 160 | end 161 | 162 | context "2 level nesting requested" do 163 | let(:includes) { ["level1.level2"] } 164 | 165 | it "lazy evaluates up to level 2" do 166 | expect do 167 | expect do 168 | expect do 169 | json 170 | end.to make_database_queries(count: 1, matching: "blog_posts") 171 | end.to make_database_queries(count: 1, matching: "categories") 172 | end.to make_database_queries(count: 1, matching: "category_followers") # Needed to render ids 173 | end 174 | 175 | context "when an association returns nil" do 176 | before do 177 | record = level1_records.first 178 | 179 | record.category = nil 180 | record.save(validate: false) 181 | end 182 | 183 | it "renders nil correctly" do 184 | null_relationship_data = 185 | json.dig(:included).first[:relationships][:level2][:data] 186 | expect(null_relationship_data).to eq(nil) 187 | end 188 | 189 | it "prevents N+1 queries" do 190 | expect do 191 | expect do 192 | expect do 193 | json 194 | end.to make_database_queries(count: 1, matching: "blog_posts") 195 | end.to make_database_queries(count: 1, matching: "categories") 196 | end.to make_database_queries(count: 1, matching: "category_followers") # Needed to render ids 197 | end 198 | end 199 | end 200 | 201 | context "when relationship exceeds max lazy nesting levels" do 202 | let(:includes) { ["level1.level2"] } 203 | 204 | before do 205 | stub_const("AmsLazyRelationships::Core::Evaluation::LAZY_NESTING_LEVELS", 2) 206 | end 207 | 208 | it "doesn't lazy load deeper relationships" do 209 | expect do 210 | expect do 211 | expect do 212 | json 213 | end.to make_database_queries(count: 1, matching: "blog_posts") 214 | end.to make_database_queries(count: 1, matching: "categories") 215 | end.to make_database_queries(count: 3, matching: "category_followers") 216 | end 217 | end 218 | end 219 | 220 | describe "json" do 221 | let(:included_level1_ids) do 222 | json.dig(:user, :level1).map { |i| i[:id] } 223 | end 224 | 225 | context "0 level nesting requested" do 226 | it "lazy evaluates up to level 1" do 227 | expect do 228 | json 229 | end.not_to make_database_queries(matching: "blog_posts") 230 | end 231 | 232 | it "renders correct results" do 233 | expect(json.dig(:user, :id)).to eq(level0_record.id) 234 | end 235 | end 236 | 237 | context "1 level nesting requested" do 238 | let(:includes) { %w(level1) } 239 | 240 | it "lazy evaluates up to level 1" do 241 | expect do 242 | expect do 243 | json 244 | end.to make_database_queries(count: 1, matching: "blog_posts") 245 | end.not_to make_database_queries(matching: "categories") 246 | end 247 | 248 | it "renders correct results" do 249 | expect(included_level1_ids).to match_array(level1_records.map(&:id)) 250 | end 251 | end 252 | 253 | context "2 level nesting requested" do 254 | let(:includes) { ["level1.level2"] } 255 | 256 | it "lazy evaluates up to level 2" do 257 | expect do 258 | expect do 259 | expect do 260 | json 261 | end.to make_database_queries(count: 1, matching: "blog_posts") 262 | end.to make_database_queries(count: 1, matching: "categories") 263 | end.not_to make_database_queries(matching: "category_followers") 264 | end 265 | 266 | context "when an association returns nil" do 267 | before do 268 | record = level1_records.first 269 | 270 | record.category = nil 271 | record.save(validate: false) 272 | end 273 | 274 | it "renders nil correctly" do 275 | null_relationship_data = json.dig(:user, :level1).first[:level2] 276 | expect(null_relationship_data).to eq(nil) 277 | end 278 | 279 | it "prevents N+1 queries" do 280 | expect do 281 | expect do 282 | expect do 283 | json 284 | end.to make_database_queries(count: 1, matching: "blog_posts") 285 | end.to make_database_queries(count: 1, matching: "categories") 286 | end.not_to make_database_queries(matching: "category_followers") 287 | end 288 | end 289 | end 290 | 291 | context "when relationship exceeds max lazy nesting levels" do 292 | let(:includes) { ["level1.level2"] } 293 | 294 | before do 295 | stub_const("AmsLazyRelationships::Core::Evaluation::LAZY_NESTING_LEVELS", 1) 296 | end 297 | 298 | it "doesn't lazy load deeper relationships" do 299 | expect do 300 | expect do 301 | expect do 302 | json 303 | end.to make_database_queries(count: 1, matching: "blog_posts") 304 | end.to make_database_queries(count: 3, matching: "categories") 305 | end.not_to make_database_queries(matching: "category_followers") 306 | end 307 | end 308 | end 309 | 310 | describe "lazy_has_many" do 311 | let(:includes) { "level1" } 312 | 313 | let(:level0_serializer_class) do 314 | class Level1Serializer3 < BaseTestSerializer 315 | end 316 | 317 | class Level0Serializer3 < BaseTestSerializer 318 | lazy_has_many :level1, 319 | serializer: Level1Serializer3, 320 | loader: AmsLazyRelationships::Loaders::Association.new( 321 | "Account", :blog_posts 322 | ) 323 | end 324 | 325 | Level0Serializer3 326 | end 327 | 328 | it "provides a convenience method for lazy relationships" do 329 | ids = json.dig(:user, :level1).map { |x| x[:id] } 330 | expect(ids).to match_array(level1_records.map(&:id)) 331 | end 332 | end 333 | 334 | describe "lazy_has_one" do 335 | let(:includes) { "level1" } 336 | let(:comment) { Comment.create!(user_id: user.id) } 337 | let(:serializer) do 338 | level0_serializer_class.new(comment) 339 | end 340 | let(:level0_serializer_class) do 341 | class Level1Serializer4 < BaseTestSerializer 342 | end 343 | 344 | class Level0Serializer4 < BaseTestSerializer 345 | lazy_has_one :level1, 346 | serializer: Level1Serializer4, 347 | loader: AmsLazyRelationships::Loaders::Association.new( 348 | "Comment", :user 349 | ) 350 | 351 | attribute :conditional_level1 do 352 | lazy_level1 ? 'exists' : 'missing' 353 | end 354 | 355 | attribute :safe_navigated_level1 do 356 | lazy_level1&.id 357 | end 358 | end 359 | 360 | Level0Serializer4 361 | end 362 | 363 | it "provides a convenience method for lazy relationships" do 364 | id = json.dig(:comment, :level1, :id) 365 | expect(id).to eq(comment.user_id) 366 | end 367 | 368 | it "realizes the presence of relationship object through trivial condition" do 369 | conditional_level1 = json.dig(:comment, :conditional_level1) 370 | expect(conditional_level1).to eq('exists') 371 | end 372 | 373 | it "realizes the presence of relationship object through safe navigation" do 374 | conditional_level1 = json.dig(:comment, :safe_navigated_level1) 375 | expect(conditional_level1).to eq(user.id) 376 | end 377 | 378 | context 'missing level1' do 379 | let(:comment) { Comment.create!(user_id: nil) } 380 | 381 | it "realizes the absence of relationship object through trivial condition" do 382 | conditional_level1 = json.dig(:comment, :conditional_level1) 383 | expect(conditional_level1).to eq('missing') 384 | end 385 | 386 | it "realizes the absence of relationship object through safe navigation" do 387 | conditional_level1 = json.dig(:comment, :safe_navigated_level1) 388 | expect(conditional_level1).to be_nil 389 | end 390 | end 391 | end 392 | 393 | describe "lazy_belongs_to" do 394 | let(:includes) { "level1" } 395 | let(:comment) { Comment.create!(user_id: user.id) } 396 | let(:serializer) do 397 | level0_serializer_class.new(comment) 398 | end 399 | let(:level0_serializer_class) do 400 | class Level1Serializer5 < BaseTestSerializer 401 | end 402 | 403 | class Level0Serializer5 < BaseTestSerializer 404 | lazy_belongs_to :level1, 405 | serializer: Level1Serializer5, 406 | loader: AmsLazyRelationships::Loaders::Association.new( 407 | "Comment", :user 408 | ) 409 | 410 | attribute :conditional_level1 do 411 | lazy_level1 ? 'exists' : 'missing' 412 | end 413 | 414 | attribute :safe_navigated_level1 do 415 | lazy_level1&.id 416 | end 417 | end 418 | 419 | Level0Serializer5 420 | end 421 | 422 | it "provides a convenience method for lazy relationships" do 423 | id = json.dig(:comment, :level1, :id) 424 | expect(id).to eq(comment.user_id) 425 | end 426 | 427 | it "realizes the presence of relationship object through trivial condition" do 428 | conditional_level1 = json.dig(:comment, :conditional_level1) 429 | expect(conditional_level1).to eq('exists') 430 | end 431 | 432 | it "realizes the presence of relationship object through safe navigation" do 433 | conditional_level1 = json.dig(:comment, :safe_navigated_level1) 434 | expect(conditional_level1).to eq(user.id) 435 | end 436 | 437 | context 'missing level1' do 438 | let(:comment) { Comment.create!(user_id: nil) } 439 | 440 | it "realizes the absence of relationship object through trivial condition" do 441 | conditional_level1 = json.dig(:comment, :conditional_level1) 442 | expect(conditional_level1).to eq('missing') 443 | end 444 | 445 | it "realizes the absence of relationship object through safe navigation" do 446 | conditional_level1 = json.dig(:comment, :safe_navigated_level1) 447 | expect(conditional_level1).to be_nil 448 | end 449 | end 450 | 451 | describe "passing block to lazy_belongs_to" do 452 | let(:includes) { "level1" } 453 | let(:level0_serializer_class) do 454 | class Level1Serializer6 < BaseTestSerializer 455 | attributes :name 456 | end 457 | 458 | class Level0Serializer6 < BaseTestSerializer 459 | lazy_belongs_to :level1, 460 | serializer: Level1Serializer6, 461 | loader: AmsLazyRelationships::Loaders::Association.new( 462 | "Comment", :user 463 | ) do |serializer| 464 | if object.user_id 465 | ll1 = serializer.lazy_level1 466 | ll1.name = "x" 467 | ll1 468 | end 469 | end 470 | end 471 | 472 | Level0Serializer6 473 | end 474 | 475 | it "yields serializer object and lets to use 'object' method" do 476 | id = json.dig(:comment, :level1, :id) 477 | expect(id).to eq(comment.user_id) 478 | serialized_name = json.dig(:comment, :level1, :name) 479 | expect(serialized_name).to eq("x") 480 | end 481 | end 482 | end 483 | 484 | describe "loader option" do 485 | let(:level0_serializer_class) do 486 | class Level1Serializer7 < BaseTestSerializer 487 | end 488 | 489 | class Level0Serializer7 < BaseTestSerializer 490 | has_many :level1, serializer: Level1Serializer7 491 | lazy_relationship :blog_posts 492 | 493 | def level1 494 | lazy_blog_posts 495 | end 496 | end 497 | 498 | Level0Serializer7 499 | end 500 | 501 | it "uses the Loaders::Association by default" do 502 | expect { json }.not_to raise_error 503 | end 504 | end 505 | 506 | describe "load_for option" do 507 | class UserDecorator 508 | alias :read_attribute_for_serialization :send 509 | 510 | def initialize(object) 511 | @object = object 512 | end 513 | 514 | attr_reader :object 515 | 516 | delegate :id, :blog_posts, to: :object 517 | 518 | def self.model_name 519 | @_model_name ||= User.model_name 520 | end 521 | end 522 | 523 | let(:level0_record) do 524 | UserDecorator.new(user) 525 | end 526 | 527 | let!(:level0_serializer_class) do 528 | class Level1Serializer8 < BaseTestSerializer 529 | end 530 | 531 | class Level0Serializer8 < BaseTestSerializer 532 | has_many :level1, serializer: Level1Serializer8 do |s| 533 | s.lazy_level1 534 | end 535 | lazy_relationship :level1, 536 | loader: AmsLazyRelationships::Loaders::Association.new( 537 | "User", :blog_posts 538 | ), 539 | load_for: :object 540 | 541 | def level1 542 | lazy_blog_posts 543 | end 544 | end 545 | 546 | Level0Serializer8 547 | end 548 | 549 | it "executes the loader on the object pointed by the symbol" do 550 | expect(level0_record). 551 | to receive(:object).at_least(:once).and_call_original 552 | expect { json }.not_to raise_error 553 | end 554 | end 555 | 556 | describe "inheritance of lazy relationships" do 557 | let(:level0_serializer_class) do 558 | class Level2Serializer9 < BaseTestSerializer 559 | end 560 | 561 | class Level1Serializer9 < BaseTestSerializer 562 | lazy_has_one :level2, serializer: Level2Serializer9 563 | lazy_relationship :level2, loader: AmsLazyRelationships::Loaders::SimpleBelongsTo.new( 564 | "Category" 565 | ) 566 | end 567 | 568 | class Level1Serializer9Inherited < Level1Serializer9 569 | end 570 | 571 | class Level0Serializer9 < BaseTestSerializer 572 | has_many :level1, serializer: Level1Serializer9Inherited do |s| 573 | s.lazy_level1 574 | end 575 | lazy_relationship :level1, loader: AmsLazyRelationships::Loaders::Association.new( 576 | "User", :blog_posts 577 | ) 578 | end 579 | 580 | Level0Serializer9 581 | end 582 | 583 | let(:includes) { ["level1.level2"] } 584 | 585 | it "copies relationships to inherited serializer" do 586 | expect do 587 | expect do 588 | json 589 | end.to make_database_queries(count: 1, matching: "blog_posts") 590 | end.to make_database_queries(count: 1, matching: "categories") 591 | end 592 | end 593 | 594 | shared_examples "lazy loader for nested serializer" do 595 | let(:adapter_class) { json_api_adapter_class } 596 | let(:json) do 597 | JSON.parse( 598 | adapter_class.new( 599 | serializer, include: includes 600 | ).to_json 601 | ) 602 | end 603 | let(:includes) { ["blog_posts.category"] } 604 | let(:blog_post_payload) { json['included'].detect { |obj| obj['type'] == 'blog_posts' } } 605 | let(:category_payload) { json['included'].detect { |obj| obj['type'] == 'categories' } } 606 | 607 | it "avoids N+1 still" do 608 | expect { json } 609 | .to make_database_queries(count: 1, matching: "blog_posts") 610 | .and make_database_queries(count: 1, matching: "categories") 611 | .and make_database_queries(count: 1, matching: "category_followers") 612 | end 613 | 614 | it "searches for nested serializer in same manner as ActiveModelSerializer do" do 615 | blog_post_attributes = blog_post_payload['attributes'].keys 616 | expect(blog_post_attributes).to match_array(['title']) 617 | 618 | category_attributes = category_payload['attributes'].keys 619 | expect(category_attributes).to match_array(%w[created_at]) 620 | end 621 | 622 | it "does not fail if nested serializer is missing" do 623 | category_follower_attributes = category_payload.dig('relationships', 'category_followers', 'data', 0).keys 624 | expect(category_follower_attributes).to match_array(%w[id category_id created_at updated_at]) 625 | end 626 | end 627 | 628 | describe "straightforward serializers lookup" do 629 | let(:level0_serializer_class) do 630 | module Serializer10 631 | class UserSerializer < BaseTestSerializer 632 | lazy_has_many :blog_posts 633 | end 634 | 635 | class UserSerializer::BlogPostSerializer < BaseTestSerializer 636 | lazy_belongs_to :category 637 | 638 | attributes :title 639 | end 640 | 641 | class UserSerializer::BlogPostSerializer::CategorySerializer < BaseTestSerializer 642 | attributes :created_at 643 | 644 | lazy_has_many :category_followers 645 | end 646 | end 647 | 648 | Serializer10::UserSerializer 649 | end 650 | 651 | include_examples "lazy loader for nested serializer" 652 | end 653 | 654 | describe "customized serializers lookup" do 655 | next unless AMS_VERSION >= Gem::Version.new("0.10.3") 656 | 657 | let(:level0_serializer_class) do 658 | module Serializer11 659 | class UserSerializer < BaseTestSerializer 660 | lazy_has_many :blog_posts 661 | end 662 | 663 | class BlogPostSerializer < BaseTestSerializer 664 | lazy_belongs_to :category 665 | 666 | attributes :title 667 | end 668 | 669 | class CategorySerializer < BaseTestSerializer 670 | attributes :created_at 671 | 672 | lazy_has_many :category_followers 673 | end 674 | end 675 | 676 | Serializer11::UserSerializer 677 | end 678 | 679 | around do |example| 680 | serializer_lookup_chain = ActiveModelSerializers.config.serializer_lookup_chain 681 | custom_serializer_lookup = -> (resource_class, serializer_class, _namespace) { 682 | "#{serializer_class.name.deconstantize}::#{resource_class.name}Serializer" 683 | } 684 | ActiveModelSerializers.config.serializer_lookup_chain = [custom_serializer_lookup] + serializer_lookup_chain 685 | 686 | example.run 687 | 688 | ActiveModelSerializers.config.serializer_lookup_chain = serializer_lookup_chain 689 | end 690 | 691 | include_examples "lazy loader for nested serializer" 692 | end 693 | 694 | describe '#lazy_dig' do 695 | context 'collection association' do 696 | let(:level0_serializer_class) do 697 | module Serializer12 698 | class CategorySerializer < BaseTestSerializer 699 | lazy_has_many :category_followers 700 | end 701 | 702 | class BlogPostSerializer < BaseTestSerializer 703 | lazy_belongs_to :category, serializer: CategorySerializer 704 | end 705 | 706 | class UserSerializer < BaseTestSerializer 707 | lazy_has_many :blog_posts, serializer: BlogPostSerializer 708 | end 709 | end 710 | 711 | Serializer12::UserSerializer 712 | end 713 | 714 | it 'does not fire unnecessary queries' do 715 | expect { json } 716 | .to make_database_queries(count: 0, matching: 'blog_posts') 717 | end 718 | 719 | context '1 level dig' do 720 | context 'success finding' do 721 | let(:level0_serializer_class) do 722 | Class.new(super()) do 723 | attribute(:blog_post_ids) { lazy_dig(:blog_posts).map(&:id) } 724 | end 725 | end 726 | 727 | it 'prevents N+1 queries' do 728 | expect { json } 729 | .to make_database_queries(count: 1, matching: 'blog_posts') 730 | .and make_database_queries(count: 0, matching: 'categories') 731 | end 732 | 733 | it 'digs association properly' do 734 | json_blog_post_ids = json.dig(:user, :blog_post_ids) 735 | expect(json_blog_post_ids).to match_array(level1_records.map(&:id)) 736 | end 737 | end 738 | 739 | context 'misspelled association' do 740 | let(:level0_serializer_class) do 741 | Class.new(super()) do 742 | attribute(:blog_post_ids) { lazy_dig(:misspelled_blog_posts).map(&:id) } 743 | 744 | class << self 745 | delegate :name, to: :superclass 746 | end 747 | end 748 | end 749 | 750 | it 'raises ArgumentError' do 751 | expect { json } 752 | .to raise_error(ArgumentError, /Undefined lazy 'misspelled_blog_posts' relationship for 'Serializer12::UserSerializer' serializer/) 753 | end 754 | end 755 | end 756 | 757 | context '2 level dig' do 758 | context 'success finding' do 759 | let(:level0_serializer_class) do 760 | Class.new(super()) do 761 | attribute(:category_ids) { lazy_dig(:blog_posts, :category).map(&:id) } 762 | end 763 | end 764 | 765 | it 'prevents N+1 queries' do 766 | expect { json } 767 | .to make_database_queries(count: 1, matching: 'blog_posts') 768 | .and make_database_queries(count: 1, matching: 'categories') 769 | .and make_database_queries(count: 0, matching: 'category_followers') 770 | end 771 | 772 | it 'digs association properly' do 773 | json_category_ids = json.dig(:user, :category_ids) 774 | expect(json_category_ids).to match_array(level2_records.map(&:id)) 775 | end 776 | end 777 | 778 | context 'misspelled association' do 779 | let(:level0_serializer_class) do 780 | Class.new(super()) do 781 | attribute(:category_ids) { lazy_dig(:blog_posts, :misspelled_category).map(&:id) } 782 | end 783 | end 784 | 785 | it 'raises ArgumentError' do 786 | expect { json } 787 | .to raise_error(ArgumentError, /Undefined lazy 'misspelled_category' relationship for 'Serializer12::BlogPostSerializer' serializer/) 788 | end 789 | end 790 | end 791 | 792 | context '3 level dig' do 793 | context 'success finding' do 794 | let(:level0_serializer_class) do 795 | Class.new(super()) do 796 | attribute(:category_follower_ids) { lazy_dig(:blog_posts, :category, :category_followers).map(&:id) } 797 | end 798 | end 799 | 800 | it 'prevents N+1 queries' do 801 | expect { json } 802 | .to make_database_queries(count: 1, matching: 'blog_posts') 803 | .and make_database_queries(count: 1, matching: 'categories') 804 | .and make_database_queries(count: 1, matching: 'category_followers') 805 | end 806 | 807 | it 'digs association properly' do 808 | json_category_follower_ids = json.dig(:user, :category_follower_ids) 809 | expect(json_category_follower_ids).to match_array(level3_records.map(&:id)) 810 | end 811 | end 812 | 813 | context 'misspelled association' do 814 | let(:level0_serializer_class) do 815 | Class.new(super()) do 816 | attribute(:category_follower_ids) { lazy_dig(:blog_posts, :category, :misspelled_category_followers).map(&:id) } 817 | end 818 | end 819 | 820 | it 'raises ArgumentError' do 821 | expect { json } 822 | .to raise_error(ArgumentError, /Undefined lazy 'misspelled_category_followers' relationship for 'Serializer12::CategorySerializer' serializer/) 823 | end 824 | end 825 | end 826 | end 827 | 828 | context 'singular association' do 829 | let(:level0_serializer_class) do 830 | module Serializer13 831 | class CategorySerializer < BaseTestSerializer 832 | lazy_has_many :category_followers 833 | end 834 | 835 | class BlogPostSerializer < BaseTestSerializer 836 | lazy_belongs_to :category, serializer: CategorySerializer 837 | 838 | attribute(:lazy_category_id) { lazy_dig(:category).id } 839 | attribute(:lazy_category_follower_ids) { lazy_dig(:category, :category_followers).map(&:id) } 840 | end 841 | 842 | class UserSerializer < BaseTestSerializer 843 | lazy_has_many :blog_posts, serializer: BlogPostSerializer 844 | end 845 | end 846 | 847 | Serializer13::UserSerializer 848 | end 849 | 850 | let(:includes) { ["blog_posts"] } 851 | let(:blog_post) { level1_records.first } 852 | let(:json_blog_post) do 853 | json 854 | .dig(:user, :blog_posts) 855 | .detect { |json_blog_post| json_blog_post[:id] == blog_post.id } 856 | end 857 | 858 | it 'prevents N+1 queries' do 859 | expect { json } 860 | .to make_database_queries(count: 1, matching: 'blog_posts') 861 | .and make_database_queries(count: 1, matching: 'categories') 862 | .and make_database_queries(count: 1, matching: 'category_followers') 863 | end 864 | 865 | it 'digs singular object for singular association' do 866 | json_category_id = json_blog_post[:lazy_category_id] 867 | expect(json_category_id).to eq(blog_post.category_id) 868 | end 869 | 870 | it 'digs collection of objects for nested collection association' do 871 | json_lazy_category_follower_ids = json_blog_post[:lazy_category_follower_ids] 872 | category_followers = level3_records.select { |cf| cf.category_id == blog_post.category_id } 873 | 874 | expect(json_lazy_category_follower_ids).to match_array(category_followers.map(&:id)) 875 | end 876 | end 877 | end 878 | 879 | describe 'include_data AMS setting' do 880 | shared_examples 'lazy loader when custom finder is specified' do 881 | let(:adapter_class) { json_api_adapter_class } 882 | let(:includes) { ['blog_posts'] } 883 | let(:blog_post_data) { json.dig(:data, :relationships, :blog_posts, :data) } 884 | 885 | it 'loads the association' do 886 | expect { json } 887 | .to make_database_queries(count: 1, matching: 'blog_posts') 888 | 889 | expect(blog_post_data).to be_present 890 | end 891 | end 892 | 893 | context 'proc-like custom finder' do 894 | let(:level0_serializer_class) do 895 | module Serializer14 896 | class User1Serializer < BaseTestSerializer 897 | lazy_has_many :blog_posts do |serializer| 898 | -> { serializer.lazy_blog_posts } 899 | end 900 | end 901 | end 902 | 903 | Serializer14::User1Serializer 904 | end 905 | 906 | include_examples 'lazy loader when custom finder is specified' 907 | end 908 | 909 | context 'non-proc custom finder' do 910 | let(:level0_serializer_class) do 911 | module Serializer14 912 | class User2Serializer < BaseTestSerializer 913 | lazy_has_many :blog_posts do |serializer| 914 | serializer.lazy_blog_posts 915 | end 916 | end 917 | end 918 | 919 | Serializer14::User2Serializer 920 | end 921 | 922 | include_examples 'lazy loader when custom finder is specified' 923 | end 924 | 925 | next unless AMS_VERSION >= Gem::Version.new("0.10.3") 926 | 927 | shared_examples 'lazy loader when include_data option is set' do 928 | let(:adapter_class) { json_api_adapter_class } 929 | let(:includes) { ['blog_posts'] } 930 | let(:category_data) { json.dig(:included, 0, :relationships, :category, :data) } 931 | 932 | it 'does not fire unnecessary SQL query' do 933 | expect { json } 934 | .to make_database_queries(count: 1, matching: 'blog_posts') 935 | .and make_database_queries(count: 0, matching: 'categories') 936 | 937 | expect(category_data).to be_nil 938 | end 939 | 940 | context 'when sideloaded' do 941 | let(:includes) { ['blog_posts.category'] } 942 | 943 | it 'fires single SQL query' do 944 | expect { json } 945 | .to make_database_queries(count: 1, matching: 'blog_posts') 946 | .and make_database_queries(count: 1, matching: 'categories') 947 | 948 | expect(category_data).to be_present 949 | end 950 | end 951 | end 952 | 953 | context 'include_data disabled globally' do 954 | let(:level0_serializer_class) do 955 | module Serializer15 956 | class BlogPost1Serializer < BaseTestSerializer 957 | lazy_belongs_to :category 958 | end 959 | 960 | class User1Serializer < BaseTestSerializer 961 | lazy_has_many :blog_posts, serializer: BlogPost1Serializer 962 | end 963 | end 964 | 965 | Serializer15::User1Serializer 966 | end 967 | 968 | around do |example| 969 | backup = ActiveModel::Serializer.config.include_data_default 970 | ActiveModel::Serializer.config.include_data_default = :if_sideloaded 971 | 972 | example.run 973 | 974 | ActiveModel::Serializer.config.include_data_default = backup 975 | end 976 | 977 | include_examples 'lazy loader when include_data option is set' 978 | end 979 | 980 | context 'include_data disabled locally with custom finder' do 981 | let(:level0_serializer_class) do 982 | module Serializer15 983 | class BlogPost2Serializer < BaseTestSerializer 984 | lazy_belongs_to :category do |serializer| 985 | include_data :if_sideloaded 986 | -> { serializer.lazy_category } 987 | end 988 | end 989 | 990 | class User2Serializer < BaseTestSerializer 991 | lazy_has_many :blog_posts, serializer: BlogPost2Serializer 992 | end 993 | end 994 | 995 | Serializer15::User2Serializer 996 | end 997 | 998 | include_examples 'lazy loader when include_data option is set' 999 | end 1000 | 1001 | context 'include_data disabled locally without custom finder' do 1002 | let(:level0_serializer_class) do 1003 | module Serializer15 1004 | class BlogPost3Serializer < BaseTestSerializer 1005 | lazy_belongs_to :category do 1006 | include_data :if_sideloaded 1007 | end 1008 | end 1009 | 1010 | class User3Serializer < BaseTestSerializer 1011 | lazy_has_many :blog_posts, serializer: BlogPost3Serializer 1012 | end 1013 | end 1014 | 1015 | Serializer15::User3Serializer 1016 | end 1017 | 1018 | include_examples 'lazy loader when include_data option is set' 1019 | end 1020 | end 1021 | end 1022 | -------------------------------------------------------------------------------- /spec/loaders/association_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe AmsLazyRelationships::Loaders::Association do 6 | extend WithArModels 7 | 8 | with_ar_models 9 | 10 | context "belongs_to associations" do 11 | let(:blog_post) { BlogPost.create! } 12 | let!(:record) { Comment.create!(blog_post_id: blog_post.id) } 13 | let(:loader) { described_class.new("Comment", :blog_post) } 14 | 15 | context "when the relationship was already cached by AR" do 16 | before do 17 | record.blog_post 18 | end 19 | 20 | it "does not call DB" do 21 | expect { loader.load(record).itself }.not_to make_database_queries 22 | end 23 | 24 | it "returns the cached relationship" do 25 | expect(loader.load(record)).to eq(blog_post) 26 | end 27 | 28 | it "does not return data immediately, but BatchLoader instance instead" do 29 | x = loader.load(record) 30 | expect(x.inspect).to include("BatchLoader") 31 | x = x.itself 32 | expect(x.inspect).to include("BlogPost") 33 | end 34 | 35 | it "yields the loaded data but only when it is required" do 36 | yielded_data = nil 37 | 38 | promise = loader.load(record) do |data| 39 | yielded_data = data 40 | end 41 | 42 | # Data should not be yielded yet 43 | expect(yielded_data).to eq(nil) 44 | 45 | promise.id 46 | 47 | expect(yielded_data).to eq([blog_post]) 48 | end 49 | 50 | context "when one of the records was cached but other not" do 51 | let(:blog_post2) { BlogPost.create! } 52 | let!(:record2) { Comment.create!(blog_post_id: blog_post2.id) } 53 | 54 | it "queries only for one record" do 55 | expect do 56 | c1 = loader.load(record) 57 | c2 = loader.load(record2) 58 | 59 | expect(c1).to eq(blog_post) 60 | expect(c2).to eq(blog_post2) 61 | end.to make_database_queries( 62 | count: 1, 63 | # If blog_post wasn't cached then a query with "id" IN() would be called 64 | matching: /SELECT.*FROM.*blog_posts.*WHERE.*blog_posts.*\"id\" = / 65 | ) 66 | end 67 | end 68 | end 69 | 70 | context "when the relationship is empty" do 71 | let(:record) { Comment.create!(blog_post_id: nil) } 72 | 73 | it "returns nil" do 74 | expect(loader.load(record)).to eq(nil) 75 | end 76 | 77 | it "yields empty array" do 78 | yielded_data = nil 79 | 80 | promise = loader.load(record) do |data| 81 | yielded_data = data 82 | end 83 | 84 | promise.itself 85 | 86 | expect(yielded_data).to eq([]) 87 | end 88 | end 89 | 90 | context "when the relationship is present" do 91 | it "calls DB" do 92 | expect { loader.load(record).try(&:id) }. 93 | to make_database_queries(count: 1) 94 | end 95 | 96 | it "returns the record" do 97 | expect(loader.load(record)).to eq(blog_post) 98 | end 99 | 100 | it "yields the loaded data" do 101 | yielded_data = nil 102 | 103 | promise = loader.load(record) do |data| 104 | yielded_data = data 105 | end 106 | 107 | promise.id 108 | 109 | expect(yielded_data).to eq([blog_post]) 110 | end 111 | end 112 | 113 | describe "batch loading" do 114 | let(:reloaded_record) do 115 | Comment.find(record.id) 116 | end 117 | let(:blog_post2) { BlogPost.create! } 118 | let!(:record2) { Comment.create!(blog_post_id: blog_post2.id) } 119 | 120 | it "calls the db only once and returns correct results" do 121 | expect do 122 | c1 = loader.load(reloaded_record) 123 | c2 = loader.load(record2) 124 | 125 | expect(c1).to eq(blog_post) 126 | expect(c2).to eq(blog_post2) 127 | end.to make_database_queries( 128 | count: 1, 129 | matching: /SELECT.*FROM.*blog_posts.*WHERE.*blog_posts.*\"id\" IN \(\?, \?\)/ 130 | ) 131 | end 132 | 133 | it "yields the loaded data" do 134 | yielded_data = nil 135 | executions = 0 136 | 137 | block = lambda do |data| 138 | executions += 1 139 | yielded_data = data 140 | end 141 | 142 | [loader.load(record, &block), loader.load(record2, &block)].map(&:id) 143 | 144 | expect(yielded_data).to match_array([blog_post, blog_post2]) 145 | expect(executions).to eq(1) 146 | end 147 | end 148 | 149 | describe "handling relation scopes" do 150 | let(:blog_post2) { BlogPost.create!(title: "x") } 151 | let(:record2) { Comment.create!(blog_post_id: blog_post2.id) } 152 | let(:loader) { described_class.new("Comment", :blog_post_with_options) } 153 | 154 | it "applies the scope" do 155 | c1 = loader.load(record) 156 | c2 = loader.load(record2) 157 | 158 | expect(c1).to eq(nil) 159 | expect(c2).to eq(blog_post2) 160 | end 161 | end 162 | end 163 | 164 | describe "has_many relationships" do 165 | let!(:record) do 166 | BlogPost.create! 167 | end 168 | let!(:comment) do 169 | Comment.create!(blog_post_id: record.id) 170 | end 171 | let(:loader) { described_class.new("BlogPost", :comments) } 172 | 173 | context "when the relationship was already cached by AR" do 174 | before do 175 | record.comments.map(&:id) 176 | end 177 | 178 | it "does not call DB" do 179 | expect { loader.load(record).map(&:id) }. 180 | not_to make_database_queries 181 | end 182 | 183 | it "returns the cached relationship" do 184 | expect(loader.load(record)).to eq([comment]) 185 | end 186 | end 187 | 188 | context "when there are no records" do 189 | before do 190 | comment.destroy 191 | end 192 | 193 | it "returns empty array" do 194 | expect(loader.load(record)).to eq([]) 195 | end 196 | end 197 | 198 | context "when the relationship is present" do 199 | it "calls DB" do 200 | expect { loader.load(record).map(&:id) }. 201 | to make_database_queries(count: 1) 202 | end 203 | 204 | it "returns the records" do 205 | expect(loader.load(record)).to eq([comment]) 206 | end 207 | end 208 | 209 | describe "batch loading" do 210 | let(:record2) { BlogPost.create! } 211 | let!(:comment2) do 212 | Comment.create!(blog_post_id: record2.id) 213 | end 214 | 215 | it "calls the db only once and returns correct results" do 216 | expect do 217 | t1 = loader.load(record) 218 | t2 = loader.load(record2) 219 | 220 | expect(t1).to eq([comment]) 221 | expect(t2).to eq([comment2]) 222 | end.to make_database_queries(count: 1) 223 | end 224 | 225 | it "yields the loaded data" do 226 | yielded_data = nil 227 | executions = 0 228 | 229 | block = lambda do |data| 230 | executions += 1 231 | yielded_data = data 232 | end 233 | 234 | promises = [loader.load(record, &block), loader.load(record2, &block)] 235 | promises.map(&:itself) # Execute lazy blocks 236 | expect(yielded_data).to match_array([comment, comment2]) 237 | expect(executions).to eq(1) 238 | end 239 | end 240 | 241 | describe "handling relation scopes" do 242 | let(:loader) { described_class.new("BlogPost", :comments_with_options) } 243 | let(:record2) { BlogPost.create! } 244 | let!(:comment2) do 245 | Comment.create!(blog_post_id: record2.id, body: "x") 246 | end 247 | let!(:comment3) do 248 | Comment.create!(blog_post_id: record2.id, body: "y") 249 | end 250 | 251 | it "applies the scope" do 252 | t1 = loader.load(record) 253 | t2 = loader.load(record2) 254 | 255 | expect(t1).to eq([]) 256 | expect(t2).to eq([comment2]) 257 | end 258 | end 259 | 260 | context "association populated by accepts_nested_attributes_for" do 261 | let(:record) { User.create!(blog_posts_attributes: [{title: 'Foo'}]) } 262 | let(:loader) { described_class.new("User", :blog_posts) } 263 | 264 | it "avoids the duplication of associated records" do 265 | expect(loader.load(record).size).to eq(1) 266 | end 267 | end 268 | end 269 | 270 | context "when loading multiple lazy associations" do 271 | let!(:record) { BlogPost.create!(user_id: user.id) } 272 | let(:user) { User.create! } 273 | let!(:comment) do 274 | Comment.create!(blog_post_id: record.id) 275 | end 276 | 277 | let(:comments_loader) do 278 | described_class.new("BlogPost", :comments) 279 | end 280 | let(:user_loader) do 281 | described_class.new("BlogPost", :user) 282 | end 283 | 284 | it "works fine" do 285 | expect(comments_loader.load(record)).to eq([comment]) 286 | expect(user_loader.load(record)).to eq(user) 287 | end 288 | end 289 | end 290 | -------------------------------------------------------------------------------- /spec/loaders/direct_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe AmsLazyRelationships::Loaders::Direct do 6 | class ModelA 7 | attr_reader :id, :model_b 8 | 9 | def initialize(id:, model_b:) 10 | @id = id 11 | @model_b = model_b 12 | end 13 | end 14 | 15 | class ModelB 16 | def initialize(id:) 17 | @id = id 18 | end 19 | end 20 | 21 | let(:record) { ModelA.new(id: 1, model_b: model_b) } 22 | let(:model_b) do 23 | ModelB.new(id: 2) 24 | end 25 | 26 | describe "load" do 27 | let(:loader) do 28 | described_class.new(:model_b, &:model_b) 29 | end 30 | 31 | context "when no block passed" do 32 | let(:loader) do 33 | described_class.new(:model_b) 34 | end 35 | 36 | it "simply calls the relationship method" do 37 | expect(loader.load(record)).to eq(model_b) 38 | end 39 | end 40 | 41 | describe "lazy loading" do 42 | let(:record2) { ModelA.new(id: 3, model_b: model_b2) } 43 | let(:model_b2) do 44 | ModelB.new(id: 4) 45 | end 46 | 47 | it "lazy loads and yields the loaded data" do 48 | yielded_data = nil 49 | executions = 0 50 | 51 | block = lambda do |data| 52 | executions += 1 53 | yielded_data = data 54 | end 55 | 56 | # Gather 57 | called = false 58 | 59 | expect(record).to receive(:model_b).and_wrap_original do |m| 60 | called = true 61 | m.call 62 | end 63 | 64 | expect(called).to eq(false) 65 | 66 | promises = [record, record2].map { |r| loader.load(r, &block) } 67 | 68 | # Lazy eval 69 | promises.map(&:itself) 70 | 71 | expect(called).to eq(true) 72 | 73 | expect(yielded_data).to match_array([model_b, model_b2]) 74 | expect(executions).to eq(1) 75 | end 76 | 77 | context "different relationships" do 78 | let(:loader2) do 79 | described_class.new(:id) 80 | end 81 | 82 | it "works fine" do 83 | expect(loader.load(record)).to eq(model_b) 84 | expect(loader2.load(record)).to eq(record.id) 85 | end 86 | end 87 | 88 | context "different classes" do 89 | let(:record2) { ModelA.new(id: 3, model_b: model_b2) } 90 | let(:model_b2) { "x" } 91 | 92 | it "works fine" do 93 | expect(loader.load(record)).to eq(model_b) 94 | expect(loader.load(record2)).to eq("x") 95 | end 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/loaders/simple_belongs_to_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe AmsLazyRelationships::Loaders::SimpleBelongsTo do 6 | extend WithArModels 7 | 8 | with_ar_models 9 | 10 | describe "load" do 11 | let(:blog_post) { BlogPost.create! } 12 | let!(:record) { Comment.create!(blog_post_id: blog_post.id) } 13 | let(:loader) { described_class.new("BlogPost") } 14 | 15 | context "when the foreign_key is nil" do 16 | before do 17 | record.update(blog_post_id: nil) 18 | end 19 | 20 | it "does not call DB" do 21 | expect { loader.load(record).itself }.not_to make_database_queries 22 | end 23 | 24 | it "returns nil" do 25 | expect(loader.load(record)).to eq(nil) 26 | end 27 | 28 | it "yields empty array" do 29 | yielded_data = nil 30 | 31 | promise = loader.load(record) do |data| 32 | yielded_data = data 33 | end 34 | 35 | promise.itself 36 | 37 | expect(yielded_data).to eq([]) 38 | end 39 | end 40 | 41 | context "when the parent record is empty" do 42 | before do 43 | BlogPost.delete_all 44 | end 45 | 46 | it "calls DB" do 47 | expect { loader.load(record).itself }. 48 | to make_database_queries(count: 1) 49 | end 50 | 51 | it "returns nil" do 52 | expect(loader.load(record)).to eq(nil) 53 | end 54 | 55 | it "yields empty array" do 56 | yielded_data = nil 57 | 58 | promise = loader.load(record) do |data| 59 | yielded_data = data 60 | end 61 | 62 | promise.itself 63 | 64 | expect(yielded_data).to eq([]) 65 | end 66 | end 67 | 68 | context "when the relationship is present" do 69 | it "calls DB" do 70 | expect { loader.load(record).itself }. 71 | to make_database_queries(count: 1) 72 | end 73 | 74 | it "returns the record" do 75 | expect(loader.load(record)).to eq(blog_post) 76 | end 77 | 78 | it "yields the loaded data" do 79 | yielded_data = nil 80 | 81 | promise = loader.load(record) do |data| 82 | yielded_data = data 83 | end 84 | 85 | promise.id 86 | 87 | expect(yielded_data).to eq([blog_post]) 88 | end 89 | 90 | context "when using foreign_key option" do 91 | let(:loader) do 92 | described_class.new("BlogPost", foreign_key: :blog_post_id) 93 | end 94 | 95 | it "works" do 96 | expect(loader.load(record)).to eq(blog_post) 97 | end 98 | end 99 | end 100 | 101 | describe "batch loading" do 102 | let(:blog_post2) { BlogPost.create! } 103 | let!(:record2) { Comment.create!(blog_post_id: blog_post2.id) } 104 | let(:blog_post3) { BlogPost.create! } 105 | let!(:record3) { Comment.create!(blog_post_id: blog_post3.id) } 106 | 107 | it "calls the db only once and returns correct results" do 108 | expect do 109 | c1 = loader.load(record) 110 | c2 = loader.load(record2) 111 | c3 = loader.load(record3) 112 | 113 | expect(c1).to eq(blog_post) 114 | expect(c2).to eq(blog_post2) 115 | expect(c3).to eq(blog_post3) 116 | end.to make_database_queries(count: 1) 117 | end 118 | 119 | it "yields the loaded data" do 120 | yielded_data = nil 121 | executions = 0 122 | 123 | block = lambda do |data| 124 | executions += 1 125 | yielded_data = data 126 | end 127 | 128 | [loader.load(record, &block), loader.load(record2, &block)].map(&:id) 129 | 130 | expect(yielded_data).to match_array([blog_post, blog_post2]) 131 | expect(executions).to eq(1) 132 | end 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /spec/loaders/simple_has_many_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe AmsLazyRelationships::Loaders::SimpleHasMany do 6 | extend WithArModels 7 | 8 | with_ar_models 9 | 10 | describe "load" do 11 | let(:record) { BlogPost.create! } 12 | let!(:comments) do 13 | 2.times.map do 14 | Comment.create!(blog_post_id: record.id) 15 | end 16 | end 17 | let(:loader) do 18 | described_class.new( 19 | "Comment", 20 | foreign_key: :blog_post_id 21 | ) 22 | end 23 | 24 | context "when the relationship is present" do 25 | it "calls DB" do 26 | expect { loader.load(record).map(&:itself) }. 27 | to make_database_queries(count: 1) 28 | end 29 | 30 | it "returns the record" do 31 | expect(loader.load(record)).to eq(comments) 32 | end 33 | 34 | it "yields the data" do 35 | yielded_data = nil 36 | 37 | promise = loader.load(record) do |data| 38 | yielded_data = data 39 | end 40 | 41 | promise.map(&:itself) 42 | 43 | expect(yielded_data).to eq(comments) 44 | end 45 | end 46 | 47 | context "when there are no records" do 48 | before do 49 | Comment.delete_all 50 | end 51 | 52 | it "calls DB" do 53 | expect { loader.load(record).map(&:itself) }. 54 | to make_database_queries(count: 1) 55 | end 56 | 57 | it "returns empty array" do 58 | expect(loader.load(record)).to eq([]) 59 | end 60 | 61 | it "yields an empty array" do 62 | yielded_data = nil 63 | 64 | promise = loader.load(record) do |data| 65 | yielded_data = data 66 | end 67 | 68 | promise.map(&:itself) 69 | 70 | expect(yielded_data).to eq([]) 71 | end 72 | end 73 | 74 | describe "batch loading" do 75 | let!(:record2) { BlogPost.create! } 76 | let!(:record2_comment) do 77 | Comment.create!(blog_post_id: record2.id) 78 | end 79 | 80 | it "calls the db only once and returns correct results" do 81 | expect do 82 | c1 = loader.load(record) 83 | c2 = loader.load(record2) 84 | 85 | expect(c1).to eq(comments) 86 | expect(c2).to eq([record2_comment]) 87 | end.to make_database_queries(count: 1) 88 | end 89 | 90 | it "yields the loaded data" do 91 | yielded_data = nil 92 | executions = 0 93 | 94 | block = lambda do |data| 95 | executions += 1 96 | yielded_data = data 97 | end 98 | 99 | # Gather and lazy evaluate 100 | [loader.load(record, &block), loader.load(record2, &block)].map(&:itself) 101 | 102 | expect(yielded_data). 103 | to match_array(comments + [record2_comment]) 104 | expect(executions).to eq(1) 105 | end 106 | end 107 | 108 | context "when record has multiple lazy has many" do 109 | let!(:record) { User.create! } 110 | let!(:comment) do 111 | Comment.create!(user_id: record.id) 112 | end 113 | let!(:blog_post) { BlogPost.create!(user_id: record.id) } 114 | 115 | let(:comments_loader) do 116 | described_class.new("Comment", foreign_key: :user_id) 117 | end 118 | let(:blog_posts_loader) do 119 | described_class.new("BlogPost", foreign_key: :user_id) 120 | end 121 | 122 | it "works fine" do 123 | expect(comments_loader.load(record)).to eq([comment]) 124 | expect(blog_posts_loader.load(record)).to eq([blog_post]) 125 | end 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "simplecov" 2 | require "simplecov-lcov" 3 | 4 | SimpleCov::Formatter::LcovFormatter.config.report_with_single_file = true 5 | SimpleCov.formatter = SimpleCov::Formatter::LcovFormatter 6 | SimpleCov.start do 7 | add_filter(/^\/spec\//) 8 | end 9 | 10 | require "bundler/setup" 11 | 12 | # That is a missing dependency of AMS v0.10.0.rc4 in fact ( 13 | require "active_support/core_ext/string/inflections" 14 | require "ams_lazy_relationships" 15 | 16 | require "undercover" 17 | require "with_model" 18 | require "pry" 19 | require "db-query-matchers" 20 | 21 | # Requires supporting ruby files with custom matchers and macros, etc, 22 | # in spec/support/ and its subdirectories. 23 | Dir[File.join(File.dirname(__FILE__), "support/**/*.rb")].each { |f| require f } 24 | 25 | RSpec.configure do |config| 26 | # Enable flags like --only-failures and --next-failure 27 | config.example_status_persistence_file_path = ".rspec_status" 28 | 29 | # Disable RSpec exposing methods globally on `Module` and `main` 30 | config.disable_monkey_patching! 31 | 32 | config.expect_with :rspec do |c| 33 | c.syntax = :expect 34 | end 35 | 36 | config.extend WithModel 37 | 38 | config.before(:all) do 39 | ActiveModelSerializers.config.key_transform = :unaltered 40 | 41 | ActiveRecord::Base.establish_connection( 42 | "adapter" => "sqlite3", 43 | "database" => ":memory:" 44 | ) 45 | end 46 | 47 | config.after do 48 | BatchLoader::Executor.clear_current 49 | end 50 | end 51 | 52 | DBQueryMatchers.configure do |config| 53 | config.ignores = [/SHOW TABLES LIKE/] 54 | config.schemaless = true 55 | end 56 | -------------------------------------------------------------------------------- /spec/support/with_ar_models.rb: -------------------------------------------------------------------------------- 1 | module WithArModels 2 | def with_ar_models 3 | with_model :User do 4 | table(id: :uuid) do |t| 5 | t.string :name 6 | t.timestamps 7 | end 8 | 9 | model do 10 | before_create { self.id = SecureRandom.uuid } 11 | has_many :comments 12 | has_many :blog_posts 13 | accepts_nested_attributes_for :blog_posts 14 | end 15 | end 16 | 17 | with_model :Category do 18 | table(id: :uuid) do |t| 19 | t.timestamps null: false 20 | end 21 | 22 | model do 23 | before_create { self.id = SecureRandom.uuid } 24 | has_many :category_followers 25 | end 26 | end 27 | 28 | with_model :CategoryFollower do 29 | table(id: :uuid) do |t| 30 | t.string :category_id 31 | 32 | t.timestamps null: false 33 | end 34 | 35 | model do 36 | before_create { self.id = SecureRandom.uuid } 37 | end 38 | end 39 | 40 | with_model :BlogPost do 41 | table(id: :uuid) do |t| 42 | t.string :title 43 | t.string :user_id 44 | t.string :category_id 45 | t.timestamps null: false 46 | end 47 | 48 | model do 49 | before_create { self.id = SecureRandom.uuid } 50 | belongs_to :user 51 | belongs_to :category 52 | has_many :comments 53 | has_many :comments_with_options, 54 | -> { where(body: "x") }, 55 | class_name: "Comment" 56 | end 57 | end 58 | 59 | with_model :Comment do 60 | table(id: :uuid) do |t| 61 | t.string :body 62 | t.string :blog_post_id 63 | t.string :user_id 64 | t.timestamps 65 | end 66 | 67 | model do 68 | before_create { self.id = SecureRandom.uuid } 69 | belongs_to :user 70 | belongs_to :blog_post 71 | belongs_to :blog_post_with_options, 72 | -> { where(title: "x") }, 73 | class_name: "BlogPost", 74 | foreign_key: "blog_post_id" 75 | end 76 | end 77 | end 78 | end 79 | --------------------------------------------------------------------------------