diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..175cb75 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @procore-oss/procore-blueprinter diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..a69deb7 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: / + schedule: + interval: "weekly" + timezone: "America/Los_Angeles" + labels: + - "dependabot" + - "dependencies" + - "github-actions" + - package-ecosystem: "bundler" + directory: / + schedule: + interval: "weekly" + timezone: "America/Los_Angeles" + labels: + - "dependabot" + - "dependencies" + - "bundler" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..85edadc --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +Checklist: + +* [ ] I have updated the necessary documentation +* [ ] I have signed off all my commits as required by [DCO](https://github.com/procore-oss/blueprinter-activerecord/blob/main/CONTRIBUTING.md) +* [ ] My build is green + + diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..7018ec0 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,40 @@ +name: Release +on: + workflow_run: + workflows: [Test] + types: [completed] + branches: [main] + workflow_dispatch: # allow manual deployment through GitHub Action UI +jobs: + release: + runs-on: ubuntu-latest + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + steps: + - uses: actions/checkout@v4 + - name: Version file changed + id: version-file-changed + uses: tj-actions/changed-files@v42 + with: + files: lib/blueprinter-activerecord/version.rb + - name: Set up Ruby + if: ${{ github.event_name == 'workflow_dispatch' || steps.version-file-changed.outputs.any_changed == 'true' }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.2 + bundler-cache: true + - name: Installing dependencies + if: ${{ github.event_name == 'workflow_dispatch' || steps.version-file-changed.outputs.any_changed == 'true' }} + run: bundle check --path=vendor/bundle || bundle install --path=vendor/bundle + - name: Build gem file + if: ${{ github.event_name == 'workflow_dispatch' || steps.version-file-changed.outputs.any_changed == 'true' }} + run: bundle exec rake build + - uses: fac/ruby-gem-setup-credentials-action@v2 + if: ${{ github.event_name == 'workflow_dispatch' || steps.version-file-changed.outputs.any_changed == 'true' }} + with: + user: "" + key: rubygems + token: ${{secrets.RUBY_GEMS_API_KEY}} + - uses: fac/ruby-gem-push-action@v2 + if: ${{ github.event_name == 'workflow_dispatch' || steps.version-file-changed.outputs.any_changed == 'true' }} + with: + key: rubygems diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..2ed692c --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,38 @@ +# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. +# +# You can adjust the behavior by modifying this file. +# For more information, see: +# https://github.com/actions/stale +name: 'Close stale issues and PRs' +on: + schedule: + - cron: '30 1 * * *' # https://crontab.guru/#30_1_*_*_* (everyday at 0130) + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v8 + with: + repo-token: + stale-issue-message: > + This issue is stale because it has been open for 30 days with no activity + and will be closed in 14 days unless you add a comment. + stale-pr-message: > + This PR is stale because it has been open for 30 days with no activity + and will be closed in 14 days unless you add a comment. + close-issue-message: > + This issue was closed because it has been stalled for 14 days with no activity. + close-pr-message: > + This PR was closed because it has been stalled for 14 days with no activity. + days-before-issue-stale: 30 + days-before-pr-stale: 30 + days-before-issue-close: 14 + days-before-pr-close: 14 + stale-issue-label: 'no-issue-activity' + stale-pr-label: 'no-pr-activity' + exempt-pr-labels: 'dependencies,work-in-progress' # comma separated list of labels + exempt-issue-labels: 'dependencies,work-in-progress' # comma separated list of labels diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..4fdf408 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,29 @@ +name: Test +on: + push: + branches: [main] + pull_request: + branches: [main] +permissions: + contents: read +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest] + ruby: ['3.0', '3.1', '3.2'] + include: + - os: ubuntu-20.04 + ruby: '2.7' + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby ${{ matrix.ruby }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Installing dependencies + run: bundle check --path=vendor/bundle || bundle install --path=vendor/bundle + - name: Run tests + run: bundle exec rake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b114fe8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/Gemfile.lock +/gemfiles/.bundle +/gemfiles/*.gemfile.lock diff --git a/Appraisals b/Appraisals new file mode 100644 index 0000000..b0adbc7 --- /dev/null +++ b/Appraisals @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +appraise "ar-7.1" do + gem "activerecord", "~> 7.1.1" +end + +appraise "ar-7.0" do + gem "activerecord", "~> 7.0.8" +end + +appraise "ar-6.1" do + gem "activerecord", "~> 6.1.7" +end diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..48165a7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +### 0.5.0 (2024-01-26) + +* Adds an "auto" mode that automatically preloads every Blueprint-rendered query +* Adds a "dynamic" mode that allows a single block to decide which Blueprint-rendered queries get preloaded +* Adds two logging extensions for gathering preload stats before and after implementing the Preloader extension + +### 0.4.0 (2024-01-18) + +* Remove the Blueprinter reflection and extension stubs +* Require Blueprinter >= 1.0 + +### 0.3.1 (2023-12-04) + +* Switches to a real Blueprinter Extension +* Autodetects Blueprinter and view on render +* Allows using `:includes` or `:eager_load` instead of `:preload` + +### 0.1.3 (2023-10-16) + +* [BUGFIX] Stop preloading when we hit a dynamic blueprint + +### 0.1.2 (2023-10-03) + +* [BUGFIX] Associations from included views were being missed + +### 0.1.1 (2023-09-29) + +* [BUGFIX] Open up to all 0.x blueprinter versions + +### 0.1.0 (2023-09-25) + +* [BUGFIX] Associations weren't being found if they used a custom name (e.g. `association :widget, blueprint: WidgetBlueprint, name: :wdgt`) + +## 0.0.1 (2023-09-20) + +* Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d5b8ff2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing to Procore Projects + +This document explains the common procedures expected by contributors while submitting code to Procore open source projects. + +## Code of Conduct + +Please read and abide by the [Code of Conduct](CODE_OF_CONDUCT.md) + +## General workflow + +Once a GitHub issue is accepted and assigned to you, please follow general workflow in order to submit your contribution: + +1. Fork the target repository under your GitHub username. +2. Create a branch in your forked repository for the changes you are about to make. +3. Commit your changes in the branch you created in step 2. All commits need to be signed-off. Check the [legal](#legal) section bellow for more details. +4. Push your commits to your remote fork. +5. Create a Pull Request from your remote fork pointing to the HEAD branch (usually `main` branch) of the target repository. +6. Check the GitHub build and ensure that all checks are green. + +## Legal + +Procore projects use Developer Certificate of Origin ([DCO](https://GitHub.com/apps/dco/)). + +Please sign-off your contributions by doing ONE of the following: + +* Use `git commit -s ...` with each commit to add the sign-off or +* Manually add a `Signed-off-by: Your Name ` to each commit message. + +The email address must match your primary GitHub email. You do NOT need cryptographic (e.g. gpg) signing. + +* Use `git commit -s --amend ...` to add a sign-off to the latest commit, if you forgot. + +*Note*: Some projects will provide specific configuration to ensure all commits are signed-off. Please check the project's documentation for more details. + +## Tests + +Make sure your changes are properly covered by automated tests. We aim to build an efficient test suite that is low cost to maintain and bring value to project. Prefer writing unit-tests over heavy end-to-end (e2e) tests. However, sometimes e2e tests are necessary. If you aren't sure, ask one of the maintainers about the requirements for your pull-request. diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..7f4f5e9 --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gemspec diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..a9affa8 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,20 @@ +Copyright 2023 Procore Technologies, Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a892151 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +[![Discord](https://img.shields.io/badge/Chat-EDEDED?logo=discord)](https://discord.gg/PbntEMmWws) + +# blueprinter-activerecord + +*blueprinter-activerecord* is a [blueprinter](https://github.com/procore-oss/blueprinter) extension to help you easily preload the associations from your Blueprints, N levels deep. It also provides logging extensions so you can measure how effective the primary extension is/will be. + +## Installation + +Add `blueprinter-activerecord` to your Gemfile and enable the extension using one of the configurations below. + +## Configurations + +### Automatic mode + +In automatic mode, every query (`ActiveRecord::Relation`) passed to a Blueprint will automatically be preloaded during render. + +```ruby +Blueprinter.configure do |config| + config.extensions << BlueprinterActiveRecord::Preloader.new(auto: true) +end +``` + +If you'd prefer to use `includes` rather than `preload`, pass `use: :includes`. + +### Dynamic mode + +In dynamic mode, each query passed to a Blueprint is evaluated by the block. If it returns `true`, the query will be preloaded during render. + +```ruby +Blueprinter.configure do |config| + config.extensions << BlueprinterActiveRecord::Preloader.new do |q, blueprint, view, options| + # examine q, q.model, blueprint, view, or options and return true or false + end +end +``` + +If you'd prefer to use `includes` rather than `preload`, pass `use: :includes`. + +### Manual mode + +In manual mode, nothing happens automatically; you'll need to opt individual queries into preloading. + +```ruby +Blueprinter.configure do |config| + config.extensions << BlueprinterActiveRecord::Preloader.new +end +``` + +The `preload_blueprint` method is used to opt queries in: + +```ruby +q = Widget. + where(...). + order(...). + preload_blueprint + +# preloading happens during "render" +json = WidgetBlueprint.render(q, view: :extended) +``` + +If you'd prefer to use `includes` or `eager_load` rather than `preload`, pass the `use` option: + +```ruby + preload_blueprint(use: :includes) +``` + +## Notes on use + +### Pass the *query* to render, not query *results* + +If the query runs before being passed to render, no preloading can take place. + +```ruby +# Oops - the query already ran :( +widgets = Widget.where(...).to_a +WidgetBlueprint.render(widgets, view: :extended) + +# Yay! :) +widgets = Widget.where(...) +WidgetBlueprint.render(widgets, view: :extended) +``` + +If you **must** run the query first, there is a way: + +```ruby +widgets = Widget. + where(...). + # preloading will happen HERE b/c we gave it all the info it needs + preload_blueprint(WidgetBlueprint, :extended). + to_a +do_something widgets +WidgetBlueprint.render(widgets, view: :extended) +``` + +### Look out for hidden associations + +*blueprinter-activerecord* may feel magical, but it's not magic. Some associations may be "hidden" and you'll need to preload them the old-fashioned way. + +```ruby +# Here's a Blueprint with one association and one field +class WidgetBlueprint < Blueprinter::Base + association :category, blueprint: CategoryBlueprint + field :parts_description + ... +end + +class Widget < ActiveRecord::Base + belongs_to :category + has_many :parts + + # The field is this instance method, and Blueprinter can't see inside it + def parts_description + # I'm calling the "parts" association but no one knows! + parts.map(&:description).join(", ") + end +end + +q = Widget.where(...).order(...). + # Since "category" is declared in the Blueprint, it will automatically be preloaded during "render". + # But because "parts" is hidden inside of a method call, we must manually preload it. + preload(:parts). + # catch any other hidden associations + strict_loading + +WidgetBlueprint.render(q) +``` + +Rails 6.1 added support for `strict_loading`. Depending on your configuration, it will either raise exceptions or log warnings if a query triggers any lazy loading. Very useful for catching any associations Blueprinter can't see. + +## Logging + +There are two different logging extensions. You can use them together or separately to measure how much the Preloder extension is, or can, help your application. + +### Missing Preloads Logger + +This extension is useful for measuring how helpful `BlueprinterActiveRecord::Preloader` will be for your application. It can be used with or without `Preloader`. Any Blueprint-rendered queries *not* caught by the `Preloader` extension will be caught by this logger. + +```ruby +Blueprinter.configure do |config| + # Preloader (optional) may be in in manual or dynamic mode + config.extensions << BlueprinterActiveRecord::Preloader.new + + # Catches any Blueprint-rendered queries that aren't caught by Preloader + config.extensions << BlueprinterActiveRecord::MissingPreloadsLogger.new do |info| + Rails.logger.info({ + event: "missing_preloads", + root_model: info.query.model.name, + sql: info.query.to_sql, + missing: info.found.map { |x| x.join " > " }, + percent_missing: info.percent_found, + total: info.num_existing + info.found.size, + visible: info.visible.size, + trace: info.trace, + }.to_json) + end +end +``` + +### Added Preloads Logger + +This extension measures how many missing preloads are being found & fixed by the preloader. Any query caught by this extension *won't* end up in `MissingPreloadsLogger`. + +```ruby +Blueprinter.configure do |config| + # Preloader (required) may be in any mode + config.extensions << BlueprinterActiveRecord::Preloader.new + + # Catches any queries found by Preloader + config.extensions << BlueprinterActiveRecord::AddedPreloadsLogger.new do |info| + Rails.logger.info({ + event: "added_preloads", + root_model: info.query.model.name, + sql: info.query.to_sql, + added: info.found.map { |x| x.join " > " }, + percent_added: info.percent_found, + total: info.num_existing + info.found.size, + visible: info.visible.size, + trace: info.trace, + }.to_json) + end +end +``` + +## Rake task + +Curious what exactly `preload_blueprint` is going to preload? There's a rake task to pretty-print the whole tree. Pass it the Blueprint, view, and ActiveRecord model: + +```bash +bundle exec rake blueprinter:activerecord:preloads[WidgetBlueprint,extended,Widget] +{ + :customer => { + :contacts => {}, + :address => {}, + }, + :parts => {}, +} +``` + +## Testing + +```bash +bundle install +bundle exec appraisal install +bundle exec appraisal rake test +``` diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..6dce539 --- /dev/null +++ b/Rakefile @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'bundler/setup' +require 'bundler/gem_tasks' +require 'minitest/test_task' + +# "test" command will accept files or dirs as args, plus N=pattern or X=pattern to include/exclude individual tests +Minitest::TestTask.create(:test) do |t| + globs = ARGV[1..].map { |x| + if Dir.exist? x + "#{x}/**/*_test.rb" + elsif File.exist? x + x + end + }.compact + + t.libs << "test" + t.libs << "lib" + t.warning = false + t.test_globs = globs.any? ? globs : ["test/**/*_test.rb"] +end diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a776a89 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,19 @@ +# Security Policy + +## Supported Versions + +Ruby versions that are currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| <=2.6 | :x: | +| 2.7 | :white_check_mark: | +| 3.0 | :white_check_mark: | +| 3.1 | :white_check_mark: | +| 3.2 | :white_check_mark: | + +## Reporting a Vulnerability + +Please click the `Report a vulnerability` button [here](https://github.com/procore-oss/blueprinter-activerecord/security) to report a vulnerability. + +A maintainer will respond to you as soon as possible and discuss the process to get the vulnerability fixed. diff --git a/blueprinter-activerecord.gemspec b/blueprinter-activerecord.gemspec new file mode 100644 index 0000000..c75a56f --- /dev/null +++ b/blueprinter-activerecord.gemspec @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative 'lib/blueprinter-activerecord/version' + +Gem::Specification.new do |spec| + spec.name = 'blueprinter-activerecord' + spec.version = BlueprinterActiveRecord::VERSION + spec.authors = ['Procore Technologies, Inc.'] + spec.email = ['opensource@procore.com'] + + spec.summary = 'Extensions for using ActiveRecord with ActiveRecord' + spec.description = 'Eager loading and other ActiveRecord helpers for Blueprinter' + spec.homepage = 'https://github.com/procore-oss/blueprinter-activerecord' + spec.license = 'MIT' + spec.required_ruby_version = Gem::Requirement.new('>= 2.7') + + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = 'https://github.com/procore-oss/blueprinter-activerecord' + spec.metadata['changelog_uri'] = 'https://github.com/procore-oss/blueprinter-activerecord/CHANGELOG.md' + + spec.files = Dir['lib/**/*'] + spec.require_paths = ['lib'] + + spec.add_runtime_dependency 'activerecord', ['>= 6.0', '< 7.2'] + spec.add_runtime_dependency 'blueprinter', '~> 1.0' + + spec.add_development_dependency 'appraisal', '~> 2.5' + spec.add_development_dependency 'database_cleaner', '~> 2.0' + spec.add_development_dependency 'minitest', '~> 5.0' + spec.add_development_dependency 'rake', '~> 13.0' + spec.add_development_dependency 'sqlite3', '~> 1.4' +end diff --git a/gemfiles/ar_6.1.gemfile b/gemfiles/ar_6.1.gemfile new file mode 100644 index 0000000..93374d4 --- /dev/null +++ b/gemfiles/ar_6.1.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 6.1.7" + +gemspec path: "../" diff --git a/gemfiles/ar_7.0.gemfile b/gemfiles/ar_7.0.gemfile new file mode 100644 index 0000000..362ea5b --- /dev/null +++ b/gemfiles/ar_7.0.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 7.0.8" + +gemspec path: "../" diff --git a/gemfiles/ar_7.1.gemfile b/gemfiles/ar_7.1.gemfile new file mode 100644 index 0000000..d585948 --- /dev/null +++ b/gemfiles/ar_7.1.gemfile @@ -0,0 +1,7 @@ +# This file was generated by Appraisal + +source "https://rubygems.org" + +gem "activerecord", "~> 7.1.1" + +gemspec path: "../" diff --git a/lib/blueprinter-activerecord.rb b/lib/blueprinter-activerecord.rb new file mode 100644 index 0000000..5fc4dd7 --- /dev/null +++ b/lib/blueprinter-activerecord.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'blueprinter' +require 'active_record' + +module BlueprinterActiveRecord + autoload :QueryMethods, 'blueprinter-activerecord/query_methods' + autoload :Preloader, 'blueprinter-activerecord/preloader' + autoload :AddedPreloadsLogger, 'blueprinter-activerecord/added_preloads_logger' + autoload :MissingPreloadsLogger, 'blueprinter-activerecord/missing_preloads_logger' + autoload :PreloadInfo, 'blueprinter-activerecord/preload_info' + autoload :Helpers, 'blueprinter-activerecord/helpers' + autoload :Version, 'blueprinter-activerecord/version' +end + +ActiveRecord::Relation.send(:include, BlueprinterActiveRecord::QueryMethods) +ActiveRecord::Base.extend(BlueprinterActiveRecord::QueryMethods::Delegates) + +require 'blueprinter-activerecord/railtie' if defined? Rails::Railtie diff --git a/lib/blueprinter-activerecord/added_preloads_logger.rb b/lib/blueprinter-activerecord/added_preloads_logger.rb new file mode 100644 index 0000000..024e528 --- /dev/null +++ b/lib/blueprinter-activerecord/added_preloads_logger.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module BlueprinterActiveRecord + # + # A Blueprinter extension to log what preloads were found and added by the BlueprinterActiveRecord::Preloader extension. + # + # This extension may safely be used alongside the BlueprinterActiveRecord::MissingPreloadsLogger extension. Each query will + # only be processed by one. + # + # NOTE Only queries that pass through a Blueprint's "render" method will be found. + # + # Blueprinter.configure do |config| + # # The Preloader extension MUST be added first! + # config.extensions << BlueprinterActiveRecord::Preloader.new + # + # config.extensions << BlueprinterActiveRecord::AddedPreloadsLogger.new do |info| + # next unless info.found.any? + # + # Rails.logger.info({ + # event: "added_preloads", + # root_model: info.query.model.name, + # sql: info.query.to_sql, + # added: info.found.map { |x| x.join " > " }, + # percent_added: info.percent_found, + # trace: info.trace, + # }.to_json) + # end + # end + # + class AddedPreloadsLogger < Blueprinter::Extension + include Helpers + + # + # Initialize and configure the extension. + # + # @yield [BlueprinterActiveRecord::PreloadInfo] Your logging action + # + def initialize(&log_proc) + @log_proc = log_proc + end + + def pre_render(object, blueprint, view, options) + if object.is_a?(ActiveRecord::Relation) && object.before_preload_blueprint + from_code = object.before_preload_blueprint + from_blueprint = Preloader.preloads(blueprint, view, object.model) + info = PreloadInfo.new(object, from_code, from_blueprint, caller) + @log_proc&.call(info) + end + object + end + end +end diff --git a/lib/blueprinter-activerecord/helpers.rb b/lib/blueprinter-activerecord/helpers.rb new file mode 100644 index 0000000..1faa948 --- /dev/null +++ b/lib/blueprinter-activerecord/helpers.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module BlueprinterActiveRecord + module Helpers + extend self + + # + # Combines all types of preloads (preload, includes, eager_load) into a single nested hash + # + # @param q [ActiveRecord::Relation] + # @return [Hash] Symbol keys with Hash values of arbitrary depth + # + def extract_preloads(q) + merge_values [*q.values[:preload], *q.values[:includes], *q.values[:eager_load]] + end + + # + # Count the number of preloads in a nested Hash. + # + # @param preloads [Hash] Nested Hash of preloads + # @return [Integer] The number of associations in the Hash + # + def count_preloads(preloads) + preloads.reduce(0) { |acc, (_key, val)| + acc + 1 + count_preloads(val) + } + end + + # + # Finds preloads from 'after' that are missing in 'before'. + # + # @param before [Hash] The extracted preloads from before Preloader ran + # @param after [Hash] The extracted preloads from after Preloader ran + # @param diff [Array] internal use + # @param path [Array] internal use + # @return [Array>] the preloads missing from 'before' . They're in a "path" structure, with the last element of each sub-array being the missing preload, e.g. `[[:widget], [:project, :company]]` + # + def diff_preloads(before, after, diff = [], path = []) + after.each_with_object(diff) do |(key, after_val), obj| + sub_path = path + [key] + before_val = before[key] + obj << sub_path if before_val.nil? + diff_preloads(before_val || {}, after_val, diff, sub_path) + end + end + + # + # Merges 'values', which may be any nested structure of arrays, hashes, strings, and symbols into a nested hash. + # + # @param value [Array|Hash|String|Symbol] + # @param result [Hash] + # @return [Hash] Symbol keys with Hash values of arbitrary depth + # + def merge_values(value, result = {}) + case value + when Array + value.each { |val| merge_values(val, result) } + when Hash + value.each { |key, val| + key = key.to_sym + result[key] ||= {} + merge_values(val, result[key]) + } + when Symbol + result[value] ||= {} + when String + result[value.to_sym] ||= {} + else + raise ArgumentError, "Unexpected value of type '#{value.class.name}' (#{value.inspect})" + end + result + end + end +end diff --git a/lib/blueprinter-activerecord/missing_preloads_logger.rb b/lib/blueprinter-activerecord/missing_preloads_logger.rb new file mode 100644 index 0000000..b172de2 --- /dev/null +++ b/lib/blueprinter-activerecord/missing_preloads_logger.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module BlueprinterActiveRecord + # + # A Blueprinter extension to log what COULD have been preloaded with the BlueprinterActiveRecord::Preloader extension. + # + # This extension may safely be used alongside the BlueprinterActiveRecord::Preloader and BlueprinterActiveRecord::AddedPreloadsLogger + # extensions. Any queries processed by those extensions will be ignored by this one. + # + # NOTE Only queries that pass through a Blueprint's "render" method will be found. + # + # Blueprinter.configure do |config| + # config.extensions << BlueprinterActiveRecord::MissingPreloadsLogger.new do |info| + # next unless info.found.any? + # + # Rails.logger.info({ + # event: "missing_preloads", + # root_model: info.query.model.name, + # sql: info.query.to_sql, + # missing: info.found.map { |x| x.join " > " }, + # percent_missing: info.percent_found, + # trace: info.trace, + # }.to_json) + # end + # end + # + class MissingPreloadsLogger < Blueprinter::Extension + include Helpers + + # + # Initialize and configure the extension. + # + # @yield [BlueprinterActiveRecord::PreloadInfo] Your logging action + # + def initialize(&log_proc) + @log_proc = log_proc + end + + def pre_render(object, blueprint, view, options) + if object.is_a?(ActiveRecord::Relation) && !object.before_preload_blueprint + from_code = extract_preloads object + from_blueprint = Preloader.preloads(blueprint, view, object.model) + info = PreloadInfo.new(object, from_code, from_blueprint, caller) + @log_proc&.call(info) + end + object + end + end +end diff --git a/lib/blueprinter-activerecord/preload_info.rb b/lib/blueprinter-activerecord/preload_info.rb new file mode 100644 index 0000000..48fa817 --- /dev/null +++ b/lib/blueprinter-activerecord/preload_info.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module BlueprinterActiveRecord + # + # Info about preloads from a query that was run through a Blueprinter's render method. + # + # Used for logging by the BlueprinterActiveRecord::MissingPreloadsLogger and BlueprinterActiveRecord::AddedPreloadsLogger extensions. + # + class PreloadInfo + include Helpers + + # @return [ActiveRecord::Relation] The base query + attr_reader :query + + # @return [Array] Stack trace to the query + attr_reader :trace + + # + # @param query [ActiveRecord::Relation] The query passed to "render" + # @param from_code [Hash] Nested Hash of preloads, includes, and eager_loads that were present in query when passed to "render" + # @param from_blueprint [Hash] Nested Hash of associations pulled from the Blueprint view + # @param trace [Array] Stack trace to query + # + def initialize(query, from_code, from_blueprint, trace) + @query = query + @from_code = from_code + @from_blueprint = from_blueprint + @trace = trace + end + + # @return [Integer] The percent of total preloads found by BlueprinterActiveRecord + def percent_found + total = num_existing + found.size + ((found.size / num_existing.to_f) * 100).round + end + + # @return [Integer] The number of preloads, includes, and eager_loads that existed before BlueprinterActiveRecord was involved + def num_existing + @num_existing ||= count_preloads(hash) + end + + # @return [Array>] Array of "preload paths" (e.g. [[:project, :company]]) to missing preloads that could have been found & added by BlueprinterActiveRecord::Preloader + def found + @found ||= diff_preloads(@from_code, hash) + end + + # @return [Array>] Array of "preload paths" (e.g. [[:project, :company]]) from the blueprint that were visible to the preloader + def visible + @visible ||= diff_preloads({}, @from_blueprint) + end + + # @return [Hash] Nested hash of all preloads, both manually added and auto found + def hash + @hash ||= merge_values [@from_code, @from_blueprint] + end + end +end diff --git a/lib/blueprinter-activerecord/preloader.rb b/lib/blueprinter-activerecord/preloader.rb new file mode 100644 index 0000000..7b65ab3 --- /dev/null +++ b/lib/blueprinter-activerecord/preloader.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module BlueprinterActiveRecord + # A Blueprinter extension to automatically preload a Blueprint view's ActiveRecord associations during render + class Preloader < Blueprinter::Extension + include Helpers + + attr_reader :use, :auto, :auto_proc + + # + # Initialize and configure the extension. + # + # @param auto [true|false] When true, preload for EVERY ActiveRecord::Relation passed to a Blueprint + # @param use [:preload|:includes] When `auto` is true, use this method (e.g. :preload) for preloading + # @yield [Object, Class, Symbol, Hash] Instead of passing `auto` as a boolean, you may define a block that accepts the object to render, the blueprint class, the view, and options. If the block returns true, auto preloading will take place. + # + def initialize(auto: false, use: :preload, &auto_proc) + @auto = auto + @auto_proc = auto_proc + @use = + case use + when :preload, :includes + use + else + raise ArgumentError, "Unknown value '#{use.inspect}' for `BlueprinterActiveRecord::Preloader` argument 'use'. Valid values are :preload, :includes." + end + end + + # + # Implements the "pre_render" Blueprinter Extension to preload associations from a view. + # If auto is true, all ActiveRecord::Relation objects will be preloaded. If auto is false, + # only queries that have called `.preload_blueprint` will be preloaded. + # + # NOTE: If auto is on, *don't* be concerned that you'll end up with duplicate preloads. Even if + # the query ends up with overlapping members in 'preload' and 'includes', ActiveRecord + # intelligently handles them. There are several unit tests which confirm this behavior. + # + def pre_render(object, blueprint, view, options) + case object + when ActiveRecord::Relation + if object.preload_blueprint_method || auto || auto_proc&.call(object, blueprint, view, options) == true + object.before_preload_blueprint = extract_preloads object + blueprint_preloads = self.class.preloads(blueprint, view, object.model) + loader = object.preload_blueprint_method || use + object.public_send(loader, blueprint_preloads) + else + object + end + else + object + end + end + + private + + # + # Returns an ActiveRecord preload plan extracted from the Blueprint and view (recursive). + # + # Example: + # + # preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :extended, Widget) + # q = Widget.where(...).order(...).preload(preloads) + # + # @param blueprint [Class] The Blueprint class + # @param view_name [Symbol] Name of the view in blueprint + # @param model [Class] The ActiveRecord model class that blueprint represents + # @return [Hash] A Hash containing preload/eager_load/etc info for ActiveRecord + # + def self.preloads(blueprint, view_name, model=nil) + view = blueprint.reflections.fetch(view_name) + view.associations.each_with_object({}) { |(_name, assoc), acc| + ref = model ? model.reflections[assoc.name.to_s] : nil + if (ref || model.nil?) && !assoc.blueprint.is_a?(Proc) + ref_model = ref && !(ref.belongs_to? && ref.polymorphic?) ? ref.klass : nil + acc[assoc.name] = preloads(assoc.blueprint, assoc.view, ref_model) + end + } + end + end +end diff --git a/lib/blueprinter-activerecord/query_methods.rb b/lib/blueprinter-activerecord/query_methods.rb new file mode 100644 index 0000000..adb480a --- /dev/null +++ b/lib/blueprinter-activerecord/query_methods.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module BlueprinterActiveRecord + module QueryMethods + module Delegates + def preload_blueprint(blueprint = nil, view = :default, use: :preload) + all.preload_blueprint(blueprint, view, use: use) + end + end + + ACTIONS = %i(preload eager_load includes).freeze + + # + # Automatically preload (or `eager_load` or `includes`) the associations in the given + # blueprint and view (recursively). + # + # You can have the Blueprint and view autodetected on render: + # + # q = Widget.where(...).preload_blueprint + # WidgetBlueprint.render(q, view: :extended) + # + # Or you can pass them up front: + # + # widgets = Widget.where(...).preload_blueprint(WidgetBlueprint, :extended).to_a + # # do something with widgets, then render + # WidgetBlueprint.render(widgets, view: :extended) + # + # @param blueprint [Class] The Blueprinter class to use (ignore to autodetect on render) + # @param view [Symbol] The Blueprinter view name to use (ignore to autodetect on render) + # @param use [Symbol] The eager loading strategy to use (:preload, :includes, :eager_load) + # @return [ActiveRecord::Relation] + # + def preload_blueprint(blueprint = nil, view = :default, use: :preload) + spawn.preload_blueprint!(blueprint, view, use: use) + end + + # See preload_blueprint + def preload_blueprint!(blueprint = nil, view = :default, use: :preload) + unless ACTIONS.include? use + valid = ACTIONS.map(&:inspect).join(", ") + raise BlueprinterError, "Unknown `preload_blueprint` method :#{use}. Valid methods are #{valid}." + end + + if blueprint and view + # preload right now + preloads = Preloader.preloads(blueprint, view, model) + public_send(use, preloads) + else + # preload during render + @values[:preload_blueprint_method] = use + self + end + end + + def preload_blueprint_method + @values[:preload_blueprint_method] + end + + # Get the preloads present before the Preloader extension ran (internal, for PreloadLogger) + def before_preload_blueprint + @values[:before_preload_blueprint] + end + + # Set the preloads present before the Preloader extension ran (internal, for PreloadLogger) + def before_preload_blueprint=(val) + @values[:before_preload_blueprint] = val + end + end +end diff --git a/lib/blueprinter-activerecord/railtie.rb b/lib/blueprinter-activerecord/railtie.rb new file mode 100644 index 0000000..b095ae4 --- /dev/null +++ b/lib/blueprinter-activerecord/railtie.rb @@ -0,0 +1,7 @@ +module BlueprinterActiveRecord + class Railtie < Rails::Railtie + rake_tasks do + Dir[File.join(File.dirname(__FILE__), "..", "tasks/*.rake")].each { |f| load f } + end + end +end diff --git a/lib/blueprinter-activerecord/version.rb b/lib/blueprinter-activerecord/version.rb new file mode 100644 index 0000000..c87baac --- /dev/null +++ b/lib/blueprinter-activerecord/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module BlueprinterActiveRecord + VERSION = "1.0.0" +end diff --git a/lib/tasks/blueprinter_activerecord.rake b/lib/tasks/blueprinter_activerecord.rake new file mode 100644 index 0000000..a04ac56 --- /dev/null +++ b/lib/tasks/blueprinter_activerecord.rake @@ -0,0 +1,21 @@ +namespace :blueprinter do + namespace :activerecord do + desc "Prints the preload plan" + task :preloads, [:blueprint, :view, :model] => :environment do |_, args| + def pretty(hash, indent = 0) + s = " " * indent + buff = "{" + hash.each { |key, val| + buff << "\n#{s} :#{key} => #{pretty val, indent + 2}," + } + buff << "\n#{s}" if hash.any? + buff << "}" + end + + model = args[:model].constantize + blueprint = args[:blueprint].constantize + preloads = BlueprinterActiveRecord::Preloader.preloads(blueprint, args[:view].to_sym, model) + puts pretty preloads + end + end +end diff --git a/test/active_record_test.rb b/test/active_record_test.rb new file mode 100644 index 0000000..97f47ef --- /dev/null +++ b/test/active_record_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'stringio' + +# NOTE These tests don't check any of THIS gem's code. They're sanity checks to ensure that new AR versions continue to behave as we expect them to. +class ActiveRecordTest < Minitest::Test + def setup + DatabaseCleaner.start + customer = Customer.create!(name: "ACME") + project = Project.create!(customer_id: customer.id, name: "Project A") + category = Category.create!(name: "Foo") + ref_plan = RefurbPlan.create!(name: "Plan A") + battery1 = LiIonBattery.create!(num_ions: 100, num_other: 100, refurb_plan_id: ref_plan.id) + battery2 = LeadAcidBattery.create!(num_lead: 100, num_acid: 100) + Widget.create!(customer_id: customer.id, project_id: project.id, category_id: category.id, name: "Widget A", battery1: battery1, battery2: battery2) + @io = StringIO.new + ActiveRecord::Base.logger = Logger.new(@io) + end + + def teardown + DatabaseCleaner.clean + ActiveRecord::Base.logger = nil + end + + def test_that_preloads_deep_merge + customers = Customer. + preload({:projects => {:widgets => {:category => {}}}}). + preload({:projects => {:widgets => [:battery1, :battery2]}}). + strict_loading + + refute_nil customers[0].projects[0].widgets[0].category + refute_nil customers[0].projects[0].widgets[0].battery1 + refute_nil customers[0].projects[0].widgets[0].battery2 + end + + def test_duplicate_preload_gets_join_loaded + Customer.includes(:projects).references(:projects).preload(:projects).to_a + @io.rewind + lines = @io.readlines + + assert_match(/LEFT OUTER JOIN "projects"/, lines[0]) + assert_nil lines[1] + end + + def test_duplicate_preload_is_ignored + Customer.includes(:projects).preload(:projects).to_a + @io.rewind + lines = @io.readlines + + refute_match(/LEFT OUTER JOIN "projects"/, lines[0]) + assert_match(/FROM "projects"/, lines[1]) + assert_nil lines[2] + end + + def test_a_more_complicated_duplication_case + Customer.includes(:projects).references(:projects).preload(:projects => {:customer => :widgets}).to_a + @io.rewind + lines = @io.readlines + + assert_match(/LEFT OUTER JOIN "projects"/, lines[0]) + assert_match(/FROM "widgets"/, lines[1]) + assert_nil lines[2] + end +end diff --git a/test/added_preloads_logger_test.rb b/test/added_preloads_logger_test.rb new file mode 100644 index 0000000..8de760a --- /dev/null +++ b/test/added_preloads_logger_test.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'test_helper' + +class AddedPreloadsLoggerTest < Minitest::Test + def setup + @info = nil + @ext = BlueprinterActiveRecord::AddedPreloadsLogger.new { |info| @info = info } + end + + def test_adds_missing_preloads + q1 = Widget.where(category_id: 42).preload(:category) + q2 = BlueprinterActiveRecord::Preloader.new { true }. + pre_render(q1, WidgetBlueprint, :short, {}) + q3 = @ext.pre_render(q2, WidgetBlueprint, :short, {}) + + assert_equal({ + :category=>{}, + :battery1=>{:fake_assoc=>{}, :refurb_plan=>{}}, + :battery2=>{:fake_assoc=>{}, :refurb_plan=>{}}, + :project=>{:customer=>{}}, + }, BlueprinterActiveRecord::Helpers.extract_preloads(q3)) + + refute_nil @info + assert_equal Widget, @info.query.model + assert_equal q1.to_sql, @info.query.to_sql + assert_equal [ + "battery1", + "battery1 > fake_assoc", + "battery1 > refurb_plan", + "battery2", + "battery2 > fake_assoc", + "battery2 > refurb_plan", + "project", + "project > customer", + ], @info.found.map { |f| f.join " > " } + assert_equal 89, @info.percent_found + end + + def test_finds_visible_blueprints + q = BlueprinterActiveRecord::Preloader.new { true }. + pre_render(Project.preload(:widgets), WidgetBlueprint, :short, {}) + @ext.pre_render(q, ProjectBlueprint, :extended, {}) + + refute_nil @info + assert_equal Project, @info.query.model + assert_equal q.to_sql, @info.query.to_sql + assert_equal [[:customer]], @info.visible + end + + def test_ignores_queries_not_from_preloader_ext + q1 = Widget.where(category_id: 42).preload(:category) + q2 = BlueprinterActiveRecord::Preloader.new { false }. + pre_render(q1, WidgetBlueprint, :short, {}) + q3 = @ext.pre_render(q2, WidgetBlueprint, :short, {}) + + assert_equal Widget, q3.model + assert_equal q1.to_sql, q3.to_sql + assert_nil @info + end +end diff --git a/test/helpers_test.rb b/test/helpers_test.rb new file mode 100644 index 0000000..9462d0b --- /dev/null +++ b/test/helpers_test.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'ostruct' + +class HelpersTest < Minitest::Test + def test_extract_preloads_empty + q = Customer.all + preloads = BlueprinterActiveRecord::Helpers.extract_preloads(q) + assert_equal({}, preloads) + end + + def test_extract_preloads_full + q = Customer.preload(:widget).includes(project: :company).eager_load(:category) + preloads = BlueprinterActiveRecord::Helpers.extract_preloads(q) + assert_equal({ + category: {}, + project: {company: {}}, + widget: {}, + }, preloads) + end + + def test_count_preloads + count = BlueprinterActiveRecord::Helpers.count_preloads({ + a: {}, + b: { + c: { + d: { + e: {}, + }, + }, + f: { + h: {}, + }, + }, + }) + assert_equal 7, count + end + + def test_diff_preloads + before = { + a: {}, + b: { + b1: {}, + }, + } + after = { + a: { + a1: { # missing + a2: { # missing + a3: {}, # missing + }, + }, + }, + b: { + b1: { + b2: {}, # missing + }, + b3: {}, # missing + }, + c: {}, + } + + diff = BlueprinterActiveRecord::Helpers.diff_preloads(before, after) + assert_equal [ + "a > a1", + "a > a1 > a2", + "a > a1 > a2 > a3", + "b > b1 > b2", + "b > b3", + "c", + ], diff.map { |d| d.join " > " } + end + + def test_merge_values + preloads = BlueprinterActiveRecord::Helpers.merge_values([ + :a, + {b: [:c, {e: :f, g: {h: :i}}]}, + {j: [:k, :l], m: :n}, + [ + {j: {foo: :bar}}, + ], + :b, + ]) + + assert_equal({ + a: {}, + b: { + c: {}, + e: { + f: {}, + }, + g: { + h: { + i: {}, + }, + }, + }, + j: { + k: {}, + l: {}, + foo: { + bar: {}, + }, + }, + m: { + n: {}, + }, + }, preloads) + end +end diff --git a/test/missing_preloads_logger_test.rb b/test/missing_preloads_logger_test.rb new file mode 100644 index 0000000..41060be --- /dev/null +++ b/test/missing_preloads_logger_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'test_helper' + +class MissingPreloadsLoggerTest < Minitest::Test + def setup + @info = nil + @ext = BlueprinterActiveRecord::MissingPreloadsLogger.new { |info| @info = info } + end + + def test_finds_missing_preloads_without_preloader_ext + q1 = Widget.where(category_id: 42).preload(:category) + q2 = @ext.pre_render(q1, WidgetBlueprint, :short, {}) + + refute_nil @info + assert_equal Widget, @info.query.model + assert_equal q1.to_sql, @info.query.to_sql + assert_equal [ + "battery1", + "battery1 > fake_assoc", + "battery1 > refurb_plan", + "battery2", + "battery2 > fake_assoc", + "battery2 > refurb_plan", + "project", + "project > customer", + ], @info.found.map { |f| f.join " > " } + assert_equal 89, @info.percent_found + end + + def test_finds_visible_blueprints + q = Project.preload(:widgets) + @ext.pre_render(q, ProjectBlueprint, :extended, {}) + + refute_nil @info + assert_equal Project, @info.query.model + assert_equal q.to_sql, @info.query.to_sql + assert_equal [[:customer]], @info.visible + end + + def test_finds_missing_preloads_with_dynamic_preloader_ext + q1 = Widget.where(category_id: 42).preload(:category) + q2 = BlueprinterActiveRecord::Preloader.new { false }. + pre_render(q1, WidgetBlueprint, :short, {}) + q3 = @ext.pre_render(q2, WidgetBlueprint, :short, {}) + + refute_nil @info + assert_equal Widget, @info.query.model + assert_equal q1.to_sql, @info.query.to_sql + assert_equal [ + "battery1", + "battery1 > fake_assoc", + "battery1 > refurb_plan", + "battery2", + "battery2 > fake_assoc", + "battery2 > refurb_plan", + "project", + "project > customer", + ], @info.found.map { |f| f.join " > " } + assert_equal 89, @info.percent_found + end + + def test_ignores_queries_from_preloader_ext + q1 = Widget.where(category_id: 42).preload(:category) + q2 = BlueprinterActiveRecord::Preloader.new { true }. + pre_render(q1, WidgetBlueprint, :short, {}) + q3 = @ext.pre_render(q2, WidgetBlueprint, :short, {}) + + assert_equal Widget, q3.model + assert_equal q1.to_sql, q3.to_sql + assert_nil @info + end +end diff --git a/test/preload_blueprint_method_test.rb b/test/preload_blueprint_method_test.rb new file mode 100644 index 0000000..79286fe --- /dev/null +++ b/test/preload_blueprint_method_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PreloadBlueprintMethodTest < Minitest::Test + def test_without + q = Widget.order(:name).select("id, name") + assert_nil q.preload_blueprint_method + end + + def test_blueprinter_default + q = Widget.preload_blueprint.order(:name).select("id, name") + assert_equal :preload, q.preload_blueprint_method + end + + def test_blueprinter_preload + q = Widget.preload_blueprint(use: :preload).order(:name).select("id, name") + assert_equal :preload, q.preload_blueprint_method + end + + def test_blueprinter_includes + q = Widget.all.preload_blueprint(use: :includes).order(:name).select("id, name") + assert_equal :includes, q.preload_blueprint_method + end + + def test_blueprinter_eager_load + q = Widget.all.preload_blueprint(use: :eager_load).order(:name).select("id, name") + assert_equal :eager_load, q.preload_blueprint_method + end +end diff --git a/test/preloader_extension_test.rb b/test/preloader_extension_test.rb new file mode 100644 index 0000000..82ccf6f --- /dev/null +++ b/test/preloader_extension_test.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PreloaderExtensionTest < Minitest::Test + def setup + DatabaseCleaner.start + customer = Customer.create!(name: "ACME") + project = Project.create!(customer_id: customer.id, name: "Project A") + category = Category.create!(name: "Foo") + ref_plan = RefurbPlan.create!(name: "Plan A") + battery1 = LiIonBattery.create!(num_ions: 100, num_other: 100, refurb_plan_id: ref_plan.id) + battery2 = LeadAcidBattery.create!(num_lead: 100, num_acid: 100) + Widget.create!(customer_id: customer.id, project_id: project.id, category_id: category.id, name: "Widget A", battery1: battery1, battery2: battery2) + Widget.create!(customer_id: customer.id, project_id: project.id, category_id: category.id, name: "Widget B", battery1: battery1) + Widget.create!(customer_id: customer.id, project_id: project.id, category_id: category.id, name: "Widget C", battery1: battery1) + Blueprinter.configure do |config| + config.extensions << BlueprinterActiveRecord::Preloader.new + end + end + + def teardown + DatabaseCleaner.clean + Blueprinter.configure do |config| + config.extensions = [] + end + end + + def test_without + q = Widget. + where("name <> ?", "Widget C"). + order(:name) + widgets = WidgetBlueprint.render_as_hash(q, view: :extended) + + assert_equal ["Widget A", 'Widget B'], widgets.map { |w| w[:name] } + assert_equal ["Project A"], widgets.map { |w| w.dig(:project, :name) }.uniq + assert_equal ["ACME"], widgets.map { |w| w.dig(:project, :customer, :name) }.uniq + assert_equal ["Foo"], widgets.map { |w| w.dig(:category, :name) }.uniq + assert_equal ["Plan A"], widgets.map { |w| [w.dig(:battery1, :refurb_plan, :name), w.dig(:battery1, :refurb_plan, :name)] }.flatten.compact.uniq + end + + def test_blueprinter_default + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + preload_blueprint. + strict_loading + widgets = WidgetBlueprint.render_as_hash(q, view: :extended) + + assert_equal ["Widget A", 'Widget B'], widgets.map { |w| w[:name] } + assert_equal ["Project A"], widgets.map { |w| w.dig(:project, :name) }.uniq + assert_equal ["ACME"], widgets.map { |w| w.dig(:project, :customer, :name) }.uniq + assert_equal ["Foo"], widgets.map { |w| w.dig(:category, :name) }.uniq + assert_equal ["Plan A"], widgets.map { |w| [w.dig(:battery1, :refurb_plan, :name), w.dig(:battery1, :refurb_plan, :name)] }.flatten.compact.uniq + end + + def test_blueprinter_preload + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + preload_blueprint(use: :preload). + strict_loading + widgets = WidgetBlueprint.render_as_hash(q, view: :extended) + + assert_equal ["Widget A", 'Widget B'], widgets.map { |w| w[:name] } + assert_equal ["Project A"], widgets.map { |w| w.dig(:project, :name) }.uniq + assert_equal ["ACME"], widgets.map { |w| w.dig(:project, :customer, :name) }.uniq + assert_equal ["Foo"], widgets.map { |w| w.dig(:category, :name) }.uniq + assert_equal ["Plan A"], widgets.map { |w| [w.dig(:battery1, :refurb_plan, :name), w.dig(:battery1, :refurb_plan, :name)] }.flatten.compact.uniq + end + + def test_blueprinter_includes + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + preload_blueprint(use: :includes). + strict_loading + widgets = WidgetBlueprint.render_as_hash(q, view: :extended) + + assert_equal ["Widget A", 'Widget B'], widgets.map { |w| w[:name] } + assert_equal ["Project A"], widgets.map { |w| w.dig(:project, :name) }.uniq + assert_equal ["ACME"], widgets.map { |w| w.dig(:project, :customer, :name) }.uniq + assert_equal ["Foo"], widgets.map { |w| w.dig(:category, :name) }.uniq + assert_equal ["Plan A"], widgets.map { |w| [w.dig(:battery1, :refurb_plan, :name), w.dig(:battery1, :refurb_plan, :name)] }.flatten.compact.uniq + end + + def test_blueprinter_eager_load + q = Widget. + where("widgets.name <> ?", "Widget C"). + order(:name). + preload_blueprint(use: :eager_load). + strict_loading + widgets = WidgetBlueprint.render_as_hash(q, view: :no_power) + + assert_equal ["Widget A", 'Widget B'], widgets.map { |w| w[:name] } + assert_equal ["Project A"], widgets.map { |w| w.dig(:project, :name) }.uniq + assert_equal ["ACME"], widgets.map { |w| w.dig(:project, :customer, :name) }.uniq + assert_equal ["Foo"], widgets.map { |w| w.dig(:category, :name) }.uniq + end + + def test_blueprinter_preload_now + q = Widget. + where("widgets.name <> ?", "Widget C"). + order(:name). + preload_blueprint(WidgetBlueprint, :extended). + strict_loading + + assert_equal [{:battery1=>{:fake_assoc=>{}, :refurb_plan=>{}}, :battery2=>{:fake_assoc=>{}, :refurb_plan=>{}}, :category=>{}, :project=>{:customer=>{}}}], q.values[:preload] + end + + def test_auto_preload + ext = BlueprinterActiveRecord::Preloader.new(auto: true) + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + strict_loading + q = ext.pre_render(q, WidgetBlueprint, :extended, {}) + + assert ext.auto + assert_equal :preload, ext.use + assert_equal [{:battery1=>{:fake_assoc=>{}, :refurb_plan=>{}}, :battery2=>{:fake_assoc=>{}, :refurb_plan=>{}}, :category=>{}, :project=>{:customer=>{}}}], q.values[:preload] + end + + def test_auto_preload_with_block_true + ext = BlueprinterActiveRecord::Preloader.new { |object| true } + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + strict_loading + q = ext.pre_render(q, WidgetBlueprint, :extended, {}) + + refute_nil ext.auto_proc + assert_equal :preload, ext.use + assert_equal [{:battery1=>{:fake_assoc=>{}, :refurb_plan=>{}}, :battery2=>{:fake_assoc=>{}, :refurb_plan=>{}}, :category=>{}, :project=>{:customer=>{}}}], q.values[:preload] + end + + def test_auto_preload_with_block_false + ext = BlueprinterActiveRecord::Preloader.new { |object| false } + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + strict_loading + q = ext.pre_render(q, WidgetBlueprint, :extended, {}) + + refute_nil ext.auto_proc + assert_equal :preload, ext.use + assert_nil q.values[:preload] + end + + def test_auto_includes + ext = BlueprinterActiveRecord::Preloader.new(auto: true, use: :includes) + q = Widget. + where("name <> ?", "Widget C"). + order(:name). + strict_loading + q = ext.pre_render(q, WidgetBlueprint, :extended, {}) + + assert ext.auto + assert_equal :includes, ext.use + assert_equal [{:battery1=>{:fake_assoc=>{}, :refurb_plan=>{}}, :battery2=>{:fake_assoc=>{}, :refurb_plan=>{}}, :category=>{}, :project=>{:customer=>{}}}], q.values[:includes] + end +end diff --git a/test/preloads_test.rb b/test/preloads_test.rb new file mode 100644 index 0000000..5bb7bf6 --- /dev/null +++ b/test/preloads_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'test_helper' + +class PreloadsTest < Minitest::Test + def test_preload_with_model + preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :extended, Widget) + assert_equal({ + category: {}, + project: {customer: {}}, + battery1: {refurb_plan: {}, fake_assoc: {}}, + battery2: {refurb_plan: {}, fake_assoc: {}}, + }, preloads) + end + + def test_preload_with_model_with_custom_names + preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :short, Widget) + assert_equal({ + category: {}, + project: {customer: {}}, + battery1: {refurb_plan: {}, fake_assoc: {}}, + battery2: {refurb_plan: {}, fake_assoc: {}}, + }, preloads) + end + + def test_preload_sans_model + preloads = BlueprinterActiveRecord::Preloader.preloads(WidgetBlueprint, :extended) + assert_equal({ + parts: {}, + category: {}, + project: {customer: {}}, + battery1: {refurb_plan: {}, fake_assoc: {}}, + battery2: {refurb_plan: {}, fake_assoc: {}}, + }, preloads) + end +end diff --git a/test/support/active_record_connection.rb b/test/support/active_record_connection.rb new file mode 100644 index 0000000..85de304 --- /dev/null +++ b/test/support/active_record_connection.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +ActiveRecord::Base.logger = nil +ActiveRecord::Base.configurations = {'test' => {'adapter' => 'sqlite3', 'database' => ':memory:'}} +ActiveRecord::Base.establish_connection(:test) diff --git a/test/support/active_record_models.rb b/test/support/active_record_models.rb new file mode 100644 index 0000000..4c986eb --- /dev/null +++ b/test/support/active_record_models.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class Customer < ActiveRecord::Base + has_many :projects + has_many :widgets, through: :projects +end + +class Project < ActiveRecord::Base + belongs_to :customer + has_many :widgets +end + +class Category < ActiveRecord::Base + belongs_to :company +end + +class Widget < ActiveRecord::Base + Part = Struct.new(:name) + + belongs_to :customer + belongs_to :project + belongs_to :category + belongs_to :battery1, polymorphic: true + belongs_to :battery2, polymorphic: true + + def parts + [Part.new('Part 1'), Part.new('Part 2')] + end +end + +class LeadAcidBattery < ActiveRecord::Base + belongs_to :refurb_plan + + def fake_assoc + {name: "Foo"} + end + + def fake_assoc2 + end +end + +class LiIonBattery < ActiveRecord::Base + belongs_to :refurb_plan + + def fake_assoc + {name: "Bar"} + end + + def fake_assoc2 + end +end + +class RefurbPlan < ActiveRecord::Base +end diff --git a/test/support/active_record_schema.rb b/test/support/active_record_schema.rb new file mode 100644 index 0000000..718ebe4 --- /dev/null +++ b/test/support/active_record_schema.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Schema + def self.load! + ActiveRecord::Base.connection.instance_eval do + create_table :customers do |t| + t.string :name, null: false + end + + create_table :projects do |t| + t.integer :customer_id, null: false + t.string :name, null: false + end + add_foreign_key :projects, :customers + + create_table :categories do |t| + t.string :name, null: false + t.text :description + end + + create_table :widgets do |t| + t.integer :customer_id, null: false + t.integer :project_id, null: false + t.integer :category_id, null: false + t.string :name, null: false + t.text :description + t.decimal :price, precision: 6, scale: 2 + t.integer :battery1_id, null: false + t.string :battery1_type, null: false + t.integer :battery2_id + t.string :battery2_type + end + add_foreign_key :widgets, :customers + add_foreign_key :widgets, :projects + add_foreign_key :widgets, :categories + + create_table :refurb_plans do |t| + t.string :name, null: false + t.text :description + end + + create_table :lead_acid_batteries do |t| + t.integer :refurb_plan_id + t.integer :num_lead, null: false + t.integer :num_acid, null: false + end + add_foreign_key :lead_acid_batteries, :refurb_plans + + create_table :li_ion_batteries do |t| + t.integer :refurb_plan_id + t.integer :num_ions, null: false + t.integer :num_other, null: false + end + add_foreign_key :li_ion_batteries, :refurb_plans + end + end +end diff --git a/test/support/blueprints.rb b/test/support/blueprints.rb new file mode 100644 index 0000000..47dcce4 --- /dev/null +++ b/test/support/blueprints.rb @@ -0,0 +1,88 @@ +class CustomerBlueprint < Blueprinter::Base + fields :id, :name +end + +class WidgetBlueprint < Blueprinter::Base +end + +class ProjectBlueprint < Blueprinter::Base + fields :id, :customer_id, :name + + view :extended do + fields :id, :name + association :customer, blueprint: CustomerBlueprint + end + + view :extended_plus do + include_view :extended + end + + view :extended_plus_with_widgets do + include_view :extended_plus + association :widgets, blueprint: WidgetBlueprint, view: :default + end +end + +class CategoryBlueprint < Blueprinter::Base + fields :id, :name + + view :extended do + fields :id, :name, :description + end +end + +class RefurbPlanBlueprint < Blueprinter::Base + fields :name, :description +end + +class FakeAssocBlueprint < Blueprinter::Base + fields :name +end + +class BatteryBlueprint < Blueprinter::Base + field :description do |battery, _opts| + case battery + when LiIonBattery + "#{battery.num_ions} parts Li ions, #{battery.num_other} parts other" + when LeadAcidBattery + "#{battery.num_lead} parts lead, #{battery.num_acid} parts acid" + end + end + association :refurb_plan, blueprint: RefurbPlanBlueprint + association :fake_assoc, blueprint: FakeAssocBlueprint + association :fake_assoc2, blueprint: ->(obj) { obj.blueprint } +end + +class PartBlueprint < Blueprinter::Base + fields :name +end + +class WidgetBlueprint < Blueprinter::Base + fields :id, :name, :price + + view :extended do + fields :id, :name, :price, :description + association :parts, blueprint: PartBlueprint + association :category, blueprint: CategoryBlueprint, view: :extended + association :project, blueprint: ProjectBlueprint, view: :extended + association :battery1, blueprint: BatteryBlueprint + association :battery2, blueprint: BatteryBlueprint + end + + view :short do + fields :id, :name, :price, :description + association :parts, blueprint: PartBlueprint + association :category, blueprint: CategoryBlueprint, view: :extended + association :project, blueprint: ProjectBlueprint, view: :extended + association :battery1, blueprint: BatteryBlueprint, name: :bat1 + association :battery2, blueprint: BatteryBlueprint, name: :bat2 + end + + view :no_power do + fields :id, :name, :price, :description + association :parts, blueprint: PartBlueprint + association :category, blueprint: CategoryBlueprint, view: :extended + association :project, blueprint: ProjectBlueprint, view: :extended + end +end + diff --git a/test/support/database_cleaner.rb b/test/support/database_cleaner.rb new file mode 100644 index 0000000..132db8f --- /dev/null +++ b/test/support/database_cleaner.rb @@ -0,0 +1,3 @@ +require 'database_cleaner/active_record' + +DatabaseCleaner.strategy = :transaction diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..245bd2b --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'blueprinter' +require 'active_record' +require 'blueprinter-activerecord' +require 'minitest/autorun' + +Dir.glob("./test/support/*.rb").each { |file| require file } + +Schema.load!