From 40b65d1ad35c6d75225493041b376d7305078590 Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Wed, 25 Jan 2023 10:08:34 -0600 Subject: [PATCH 01/34] deps: Add MySQL to CI workflow (#1398) - also add dependabot workflow - also standardize rails/ruby version quoting/ordering --- .github/dependabot.yml | 10 ++++++ .github/workflows/ruby.yml | 74 ++++++++++++++++++++++---------------- Gemfile | 3 +- 3 files changed, 55 insertions(+), 32 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..573705517 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +# Set update schedule for GitHub Actions + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every weekday + interval: "daily" diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index aeb9b1ae9..daf2c256a 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [ 'master', 'release-0-8', 'release-0-9', 'release-0-10' ] + branches: [ 'master', 'release-0-8', 'release-0-9', 'release-0-10', 'v0-11-dev' ] pull_request: branches: ['**'] @@ -10,6 +10,18 @@ jobs: tests: runs-on: ubuntu-latest services: + mysql: + image: mysql + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: test + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 postgres: image: postgres env: @@ -26,43 +38,44 @@ jobs: fail-fast: false matrix: ruby: - - 2.6 - - 2.7 + - '3.2' + - '3.1' - '3.0' - - 3.1 - - 3.2 + - '2.7' + - '2.6' rails: - - 7.0.4 - - 6.1.7 - - 6.0.6 - - 5.2.8.1 - - 5.1.7 + - '7.0' + - '6.1' + - '6.0' + - '5.2' + - '5.1' database_url: - - postgresql://postgres:password@localhost:5432/test - sqlite3:test_db + - postgresql://postgres:password@localhost:5432/test + - mysql2://root:root@127.0.0.1:3306/test exclude: - - ruby: 3.2 - rails: 6.0.6 - - ruby: 3.2 - rails: 5.2.8.1 - - ruby: 3.2 - rails: 5.1.7 - - ruby: 3.1 - rails: 6.0.6 - - ruby: 3.1 - rails: 5.2.8.1 - - ruby: 3.1 - rails: 5.1.7 + - ruby: '3.2' + rails: '6.0' + - ruby: '3.2' + rails: '5.2' + - ruby: '3.2' + rails: '5.1' + - ruby: '3.1' + rails: '6.0' + - ruby: '3.1' + rails: '5.2' + - ruby: '3.1' + rails: '5.1' - ruby: '3.0' - rails: 6.0.6 + rails: '6.0' - ruby: '3.0' - rails: 5.2.8.1 + rails: '5.2' - ruby: '3.0' - rails: 5.1.7 - - ruby: 2.6 - rails: 7.0.4 + rails: '5.1' + - ruby: '2.6' + rails: '7.0' - database_url: postgresql://postgres:password@localhost:5432/test - rails: 5.1.7 + rails: '5.1' env: RAILS_VERSION: ${{ matrix.rails }} DATABASE_URL: ${{ matrix.database_url }} @@ -73,7 +86,6 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} - - name: Install dependencies - run: bundle install --jobs 4 --retry 3 + bundler-cache: true - name: Run tests run: bundle exec rake test diff --git a/Gemfile b/Gemfile index 2535d0200..5c866218f 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,7 @@ version = ENV['RAILS_VERSION'] || 'default' platforms :ruby do gem 'pg' + gem 'mysql2' if version.start_with?('4.2', '5.0') gem 'sqlite3', '~> 1.3.13' @@ -26,4 +27,4 @@ when 'default' gem 'railties', '>= 6.0' else gem 'railties', "~> #{version}" -end \ No newline at end of file +end From 7bd836fe220cdcbdd4948ecc941d1a299f73eab1 Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Thu, 2 Feb 2023 10:22:34 -0600 Subject: [PATCH 02/34] fix: Reliably quote columns/tables (#1400) * refactor: easily quote table/column * refactor: extract table name when missing * fix: Reliably quote columns/tables * refactor: putting quoting methods together * Handle special case of * - tests * fix: hack mysql test query comparison --- lib/jsonapi/active_relation_resource.rb | 45 ++++++++++++++----- test/test_helper.rb | 35 ++++++++++++--- .../join_manager_test.rb | 31 ++++--------- 3 files changed, 73 insertions(+), 38 deletions(-) diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_resource.rb index 581ed1e02..771133b39 100644 --- a/lib/jsonapi/active_relation_resource.rb +++ b/lib/jsonapi/active_relation_resource.rb @@ -802,18 +802,29 @@ def sort_records(records, order_options, options) apply_sort(records, order_options, options) end + def sql_field_with_alias(table, field, quoted = true) + Arel.sql("#{concat_table_field(table, field, quoted)} AS #{alias_table_field(table, field, quoted)}") + end + def concat_table_field(table, field, quoted = false) - if table.blank? || field.to_s.include?('.') + if table.blank? + split_table, split_field = field.to_s.split('.') + if split_table && split_field + table = split_table + field = split_field + end + end + if table.blank? # :nocov: if quoted - quote(field) + quote_column_name(field) else field.to_s end # :nocov: else if quoted - "#{quote(table)}.#{quote(field)}" + "#{quote_table_name(table)}.#{quote_column_name(field)}" else # :nocov: "#{table.to_s}.#{field.to_s}" @@ -822,15 +833,11 @@ def concat_table_field(table, field, quoted = false) end end - def sql_field_with_alias(table, field, quoted = true) - Arel.sql("#{concat_table_field(table, field, quoted)} AS #{alias_table_field(table, field, quoted)}") - end - def alias_table_field(table, field, quoted = false) if table.blank? || field.to_s.include?('.') # :nocov: if quoted - quote(field) + quote_column_name(field) else field.to_s end @@ -838,7 +845,7 @@ def alias_table_field(table, field, quoted = false) else if quoted # :nocov: - quote("#{table.to_s}_#{field.to_s}") + quote_column_name("#{table.to_s}_#{field.to_s}") # :nocov: else "#{table.to_s}_#{field.to_s}" @@ -846,8 +853,26 @@ def alias_table_field(table, field, quoted = false) end end + def quote_table_name(table_name) + if _model_class&.connection + _model_class.connection.quote_table_name(table_name) + else + quote(table_name) + end + end + + def quote_column_name(column_name) + return column_name if column_name == "*" + if _model_class&.connection + _model_class.connection.quote_column_name(column_name) + else + quote(column_name) + end + end + + # fallback quote identifier when database adapter not available def quote(field) - "\"#{field.to_s}\"" + %{"#{field.to_s}"} end def apply_filters(records, filters, options = {}) diff --git a/test/test_helper.rb b/test/test_helper.rb index c1faea370..8f19a2577 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -462,6 +462,29 @@ def run_in_transaction? self.fixture_path = "#{Rails.root}/fixtures" fixtures :all + + def adapter_name + ActiveRecord::Base.connection.adapter_name + end + + def db_quote_identifier + case adapter_name + when 'SQLite', 'PostgreSQL' + %{"} + when 'Mysql2' + %{`} + else + fail ArgumentError, "Unhandled adapter #{adapter_name} in #{__callee__}" + end + end + + def db_true + ActiveRecord::Base.connection.quote(true) + end + + def sql_for_compare(sql) + sql.tr(db_quote_identifier, %{"}) + end end class ActiveSupport::TestCase @@ -504,8 +527,8 @@ def assert_cacheable_jsonapi_get(url, cached_classes = :all) end assert_equal( - non_caching_response.pretty_inspect, - json_response.pretty_inspect, + sql_for_compare(non_caching_response.pretty_inspect), + sql_for_compare(json_response.pretty_inspect), "Cache warmup response must match normal response" ) @@ -514,8 +537,8 @@ def assert_cacheable_jsonapi_get(url, cached_classes = :all) end assert_equal( - non_caching_response.pretty_inspect, - json_response.pretty_inspect, + sql_for_compare(non_caching_response.pretty_inspect), + sql_for_compare(json_response.pretty_inspect), "Cached response must match normal response" ) assert_equal 0, cached[:total][:misses], "Cached response must not cause any cache misses" @@ -583,8 +606,8 @@ def assert_cacheable_get(action, **args) "Cache (mode: #{mode}) #{phase} response status must match normal response" ) assert_equal( - non_caching_response.pretty_inspect, - json_response_sans_all_backtraces.pretty_inspect, + sql_for_compare(non_caching_response.pretty_inspect), + sql_for_compare(json_response_sans_all_backtraces.pretty_inspect), "Cache (mode: #{mode}) #{phase} response body must match normal response" ) assert_operator( diff --git a/test/unit/active_relation_resource_finder/join_manager_test.rb b/test/unit/active_relation_resource_finder/join_manager_test.rb index 840c90ee2..43387f38b 100644 --- a/test/unit/active_relation_resource_finder/join_manager_test.rb +++ b/test/unit/active_relation_resource_finder/join_manager_test.rb @@ -3,25 +3,12 @@ class JoinTreeTest < ActiveSupport::TestCase - def db_true - case ActiveRecord::Base.connection.adapter_name - when 'SQLite' - if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) - "1" - else - "'t'" - end - when 'PostgreSQL' - 'TRUE' - end - end - def test_no_added_joins join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource) records = PostResource.records({}) records = join_manager.join(records, {}) - assert_equal 'SELECT "posts".* FROM "posts"', records.to_sql + assert_equal 'SELECT "posts".* FROM "posts"', sql_for_compare(records.to_sql) assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) end @@ -31,7 +18,7 @@ def test_add_single_join join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, filters: filters) records = PostResource.records({}) records = join_manager.join(records, {}) - assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', records.to_sql + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) end @@ -42,7 +29,7 @@ def test_add_single_sort_join records = PostResource.records({}) records = join_manager.join(records, {}) - assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', records.to_sql + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) end @@ -53,7 +40,7 @@ def test_add_single_sort_and_filter_join join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, sort_criteria: sort_criteria, filters: filters) records = PostResource.records({}) records = join_manager.join(records, {}) - assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', records.to_sql + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) end @@ -68,7 +55,7 @@ def test_add_sibling_joins records = PostResource.records({}) records = join_manager.join(records, {}) - assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id"', records.to_sql + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id"', sql_for_compare(records.to_sql) assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:author))) @@ -81,7 +68,7 @@ def test_add_joins_source_relationship records = PostResource.records({}) records = join_manager.join(records, {}) - assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id"', records.to_sql + assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id"', sql_for_compare(records.to_sql) assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) end @@ -94,7 +81,7 @@ def test_add_joins_source_relationship_with_custom_apply sql = 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."approved" = ' + db_true - assert_equal sql, records.to_sql + assert_equal sql, sql_for_compare(records.to_sql) assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) end @@ -195,7 +182,7 @@ def test_polymorphic_join_belongs_to_just_source records = PictureResource.records({}) records = join_manager.join(records, {}) - # assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\'', records.to_sql + # assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\'', sql_for_compare(records.to_sql) assert_hash_equals({alias: 'products', join_type: :left}, join_manager.source_join_details('products')) assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.source_join_details('documents')) assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) @@ -209,7 +196,7 @@ def test_polymorphic_join_belongs_to_filter records = PictureResource.records({}) records = join_manager.join(records, {}) - # assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\'', records.to_sql + # assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\'', sql_for_compare(records.to_sql) assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) From a50cac559d389ba4ed8703bce7aa0feba6a55d81 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 3 Mar 2023 11:20:00 -0500 Subject: [PATCH 03/34] Add docker testing setup (#1403) --- .docker/ruby_versions.txt | 4 +++ .docker/scripts/test_all | 3 ++ .docker/scripts/test_mysql | 10 +++++++ .docker/scripts/test_postgresql | 10 +++++++ .docker/scripts/test_ruby | 7 +++++ .docker/scripts/test_sqlite | 10 +++++++ Dockerfile | 36 ++++++++++++++++++++++++ README.md | 37 +++++++++++++++++++++++++ docker-compose.yml | 49 +++++++++++++++++++++++++++++++++ 9 files changed, 166 insertions(+) create mode 100644 .docker/ruby_versions.txt create mode 100644 .docker/scripts/test_all create mode 100644 .docker/scripts/test_mysql create mode 100644 .docker/scripts/test_postgresql create mode 100644 .docker/scripts/test_ruby create mode 100644 .docker/scripts/test_sqlite create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.docker/ruby_versions.txt b/.docker/ruby_versions.txt new file mode 100644 index 000000000..61f4d7632 --- /dev/null +++ b/.docker/ruby_versions.txt @@ -0,0 +1,4 @@ +2.7.7 +3.0.5 +3.1.3 +3.2.1 diff --git a/.docker/scripts/test_all b/.docker/scripts/test_all new file mode 100644 index 000000000..fd86b4553 --- /dev/null +++ b/.docker/scripts/test_all @@ -0,0 +1,3 @@ +#!/bin/bash + +xargs -L 1 ./test_ruby < ruby_versions.txt diff --git a/.docker/scripts/test_mysql b/.docker/scripts/test_mysql new file mode 100644 index 000000000..2cdf9acde --- /dev/null +++ b/.docker/scripts/test_mysql @@ -0,0 +1,10 @@ +#!/bin/bash + +cd /src +bundle update; + +RUBY_VERSION=$(ruby -v); + +echo "Testing With MySQL and Ruby $RUBY_VERSION"; +export DATABASE_URL="mysql2://test:password@mysql:3306/test" +bundle exec rake test; diff --git a/.docker/scripts/test_postgresql b/.docker/scripts/test_postgresql new file mode 100644 index 000000000..6da5b5031 --- /dev/null +++ b/.docker/scripts/test_postgresql @@ -0,0 +1,10 @@ +#!/bin/bash + +cd /src +bundle update; + +RUBY_VERSION=$(ruby -v); + +echo "Testing With PostgreSQL and Ruby $RUBY_VERSION"; +export DATABASE_URL="postgresql://postgres:password@postgres:5432/test" +bundle exec rake test; diff --git a/.docker/scripts/test_ruby b/.docker/scripts/test_ruby new file mode 100644 index 000000000..0fc3f1759 --- /dev/null +++ b/.docker/scripts/test_ruby @@ -0,0 +1,7 @@ +#!/bin/bash + +rbenv global $1; + +../test_postgresql +../test_mysql +../test_sqlite diff --git a/.docker/scripts/test_sqlite b/.docker/scripts/test_sqlite new file mode 100644 index 000000000..1829d8641 --- /dev/null +++ b/.docker/scripts/test_sqlite @@ -0,0 +1,10 @@ +#!/bin/bash + +cd /src +bundle update; + +RUBY_VERSION=$(ruby -v); + +echo "Testing With SQLite and Ruby $RUBY_VERSION"; +export DATABASE_URL="sqlite3:test_db" +bundle exec rake test; diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..017ae4664 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM buildpack-deps:bullseye + +# Install rbenv +RUN git clone https://github.com/sstephenson/rbenv.git /root/.rbenv +RUN git clone https://github.com/sstephenson/ruby-build.git /root/.rbenv/plugins/ruby-build +RUN /root/.rbenv/plugins/ruby-build/install.sh +ENV PATH /root/.rbenv/bin:/root/.rbenv/shims:$PATH +RUN echo 'eval "$(rbenv init -)"' >> /etc/profile.d/rbenv.sh # or /etc/profile +RUN echo 'eval "$(rbenv init -)"' >> .bashrc + +# Install supported ruby versions +RUN echo 'gem: --no-document' >> ~/.gemrc + +COPY .docker/ruby_versions.txt / +RUN xargs -I % sh -c 'rbenv install %; rbenv global %; gem install bundler' < ruby_versions.txt +RUN rbenv rehash + +# COPY just enough to bundle. This allows for most code changes without needing to reinstall all gems +RUN mkdir src +COPY Gemfile jsonapi-resources.gemspec Rakefile ./src/ +COPY lib/jsonapi/resources/version.rb ./src/lib/jsonapi/resources/ +# This will run bundle install for each ruby version and leave the global version set as the last one. +# So we can control the default ruby version with the order in the ruby_versions.txt file, with last being the default +RUN xargs -I % sh -c 'cd src; rbenv global %; bundle install' < /ruby_versions.txt + +# Scripts +COPY .docker/scripts/* / +RUN chmod +x /test_* + +# COPY in the rest of the project +COPY lib/ ./src/lib +COPY locales/ ./src/locales +COPY test/ ./src/test +RUN ls -la + +CMD ["/test_all"] diff --git a/README.md b/README.md index 377e49304..1b29259f8 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,43 @@ gem install jsonapi-resources **For further usage see the [v0.10 alpha Guide](http://jsonapi-resources.com/v0.10/guide/)** +## Development + +There is a docker compose setup that can be used for testing your code against the supported versions of ruby and +against PostgreSQL, MYSQL, and SQLite databases. + +First build the docker image: +```bash +docker compose build +``` +Be sure to rebuild after making code changes. It should be fast after the initial build. + +### Running the tests + +The default command will run everything (it may take a while): +```bash +docker compose run tests +``` + +To test just one database against the latest ruby: +```bash +docker compose run tests ./test_mysql +docker compose run tests ./test_postgresql +docker compose run tests ./test_sqlite +``` + +To test a version of ruby against all databases: +```bash +docker compose run tests ./test_ruby 2.7.7 +``` + +The tests by default run against the latest rails version. To override that you can set the RAILS_VERSION environment +variable: + +```bash +docker compose run -e RAILS_VERSION=6.1.1 tests ./test_postgresql +``` + ## Contributing 1. Submit an issue describing any new features you wish it add or the bug you intend to fix diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..adf2ee219 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +version: "3.9" + +networks: + back: + +services: + postgres: + image: postgres:latest + ports: + - "5432" + networks: + back: + environment: + - POSTGRES_PASSWORD=password + - POSTGRES_DB=test + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres" ] + interval: 2s + retries: 15 + + mysql: + image: mysql:latest + command: --default-authentication-plugin=mysql_native_password + ports: + - 3306 + networks: + - back + environment: + - MYSQL_DATABASE=test + - MYSQL_USER=test + - MYSQL_PASSWORD=password + - MYSQL_ROOT_PASSWORD=password + healthcheck: + test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ] + interval: 2s + retries: 15 + + tests: + image: jr_tests:latest + build: + context: . + dockerfile: Dockerfile + networks: + - back + depends_on: + postgres: + condition: service_healthy + mysql: + condition: service_healthy From 5868a378ee2c2b870a7f45fc36a4dc2e63947cec Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Thu, 9 Mar 2023 23:36:50 -0600 Subject: [PATCH 04/34] fix: test the adapter-specific query ordering (#1402) * fix: test the adapter-specific query ordering * test: make order independent * test: sort order-dependent response * test: skip failing mysql tests as ok for now --- test/controllers/controller_test.rb | 151 +++++++++++++++------- test/integration/requests/request_test.rb | 29 ++++- test/test_helper.rb | 20 +++ 3 files changed, 150 insertions(+), 50 deletions(-) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index e2568f979..e37014caf 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -494,15 +494,15 @@ def test_sorting_by_relationship_field assert_response :success assert json_response['data'].length > 10, 'there are enough records to show sort' + expected = Post + .all + .left_joins(:author) + .merge(Person.order(name: :asc)) + .map(&:id) + .map(&:to_s) + ids = json_response['data'].map {|data| data['id'] } - # Postgres sorts nulls last, whereas sqlite and mysql sort nulls first - if ENV['DATABASE_URL'].starts_with?('postgres') - assert_equal '17', json_response['data'][-1]['id'], 'nil is at the start' - assert_equal post.id.to_s, json_response['data'][0]['id'], 'alphabetically first user is not first' - else - assert_equal '17', json_response['data'][0]['id'], 'nil is at the end' - assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second' - end + assert_equal expected, ids, "since adapter_sorts_nulls_last=#{adapter_sorts_nulls_last}" end def test_desc_sorting_by_relationship_field @@ -512,14 +512,15 @@ def test_desc_sorting_by_relationship_field assert_response :success assert json_response['data'].length > 10, 'there are enough records to show sort' - # Postgres sorts nulls last, whereas sqlite and mysql sort nulls first - if ENV['DATABASE_URL'].starts_with?('postgres') - assert_equal '17', json_response['data'][0]['id'], 'nil is at the start' - assert_equal post.id.to_s, json_response['data'][-1]['id'] - else - assert_equal '17', json_response['data'][-1]['id'], 'nil is at the end' - assert_equal post.id.to_s, json_response['data'][-2]['id'], 'alphabetically first user is second last' - end + expected = Post + .all + .left_joins(:author) + .merge(Person.order(name: :desc)) + .map(&:id) + .map(&:to_s) + ids = json_response['data'].map {|data| data['id'] } + + assert_equal expected, ids, "since adapter_sorts_nulls_last=#{adapter_sorts_nulls_last}" end def test_sorting_by_relationship_field_include @@ -529,13 +530,15 @@ def test_sorting_by_relationship_field_include assert_response :success assert json_response['data'].length > 10, 'there are enough records to show sort' - if ENV['DATABASE_URL'].starts_with?('postgres') - assert_equal '17', json_response['data'][-1]['id'], 'nil is at the top' - assert_equal post.id.to_s, json_response['data'][0]['id'] - else - assert_equal '17', json_response['data'][0]['id'], 'nil is at the top' - assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second' - end + expected = Post + .all + .left_joins(:author) + .merge(Person.order(name: :asc)) + .map(&:id) + .map(&:to_s) + ids = json_response['data'].map {|data| data['id'] } + + assert_equal expected, ids, "since adapter_sorts_nulls_last=#{adapter_sorts_nulls_last}" end def test_invalid_sort_param @@ -4160,29 +4163,70 @@ def test_complex_includes_filters_nil_includes end def test_complex_includes_two_level + if is_db?(:mysql) + skip "#{adapter_name} test expectations differ in insignificant ways from expected" + end assert_cacheable_get :index, params: {include: 'things,things.user'} assert_response :success - # The test is hardcoded with the include order. This should be changed at some - # point since either thing could come first and still be valid - assert_equal '10', json_response['included'][0]['id'] - assert_equal 'things', json_response['included'][0]['type'] - assert_equal '10001', json_response['included'][0]['relationships']['user']['data']['id'] - assert_nil json_response['included'][0]['relationships']['things']['data'] + sorted_includeds = json_response['included'].map {|included| + { + 'id' => included['id'], + 'type' => included['type'], + 'relationships_user_data_id' => included['relationships'].dig('user', 'data', 'id'), + 'relationships_things_data_ids' => included['relationships'].dig('things', 'data')&.map {|data| data['id'] }&.sort, + } + }.sort_by {|included| "#{included['type']}-#{Integer(included['id'])}" } - assert_equal '20', json_response['included'][1]['id'] - assert_equal 'things', json_response['included'][1]['type'] - assert_equal '10001', json_response['included'][1]['relationships']['user']['data']['id'] - assert_nil json_response['included'][1]['relationships']['things']['data'] + expected = [ + { + 'id'=>'10', + 'type'=>'things', + 'relationships_user_data_id'=>'10001', + 'relationships_things_data_ids'=>nil + }, + { + 'id'=>'20', + 'type'=>'things', + 'relationships_user_data_id'=>'10001', + 'relationships_things_data_ids'=>nil + }, + { + 'id'=>'30', + 'type'=>'things', + 'relationships_user_data_id'=>'10002', + 'relationships_things_data_ids'=>nil + }, + { + 'id'=>'10001', + 'type'=>'users', + 'relationships_user_data_id'=>nil, + 'relationships_things_data_ids'=>['10', '20'] + }, + { + 'id'=>'10002', + 'type'=>'users', + 'relationships_user_data_id'=>nil, + 'relationships_things_data_ids'=>['30'] + }, + ] + assert_array_equals expected, sorted_includeds end def test_complex_includes_things_nested_things assert_cacheable_get :index, params: {include: 'things,things.things,things.things.things'} assert_response :success - assert_hash_equals( - { + sorted_json_response_data = json_response["data"] + .sort_by {|data| Integer(data["id"]) } + sorted_json_response_included = json_response["included"] + .sort_by {|included| "#{included['type']}-#{Integer(included['id'])}" } + sorted_json_response = { + "data" => sorted_json_response_data, + "included" => sorted_json_response_included, + } + expected_response = { "data" => [ { "id" => "100", @@ -4437,15 +4481,26 @@ def test_complex_includes_things_nested_things } } ] - }, - json_response) + } + assert_hash_equals(expected_response, sorted_json_response) end def test_complex_includes_nested_things_secondary_users + if is_db?(:mysql) + skip "#{adapter_name} test expectations differ in insignificant ways from expected" + end assert_cacheable_get :index, params: {include: 'things,things.user,things.things'} assert_response :success - assert_hash_equals( + sorted_json_response_data = json_response["data"] + .sort_by {|data| Integer(data["id"]) } + sorted_json_response_included = json_response["included"] + .sort_by {|included| "#{included['type']}-#{Integer(included['id'])}" } + sorted_json_response = { + "data" => sorted_json_response_data, + "included" => sorted_json_response_included, + } + expected = { "data" => [ { @@ -4732,8 +4787,8 @@ def test_complex_includes_nested_things_secondary_users } } ] - }, - json_response) + } + assert_hash_equals(expected, sorted_json_response) end end @@ -4767,16 +4822,22 @@ def teardown end def test_fetch_robots_with_sort_by_name + if is_db?(:mysql) + skip "#{adapter_name} test expectations differ in insignificant ways from expected" + end Robot.create! name: 'John', version: 1 Robot.create! name: 'jane', version: 1 assert_cacheable_get :index, params: {sort: 'name'} assert_response :success - if ENV['DATABASE_URL'].starts_with?('postgres') - assert_equal 'jane', json_response['data'].first['attributes']['name'] - else - assert_equal 'John', json_response['data'].first['attributes']['name'] - end + expected_names = Robot + .all + .order(name: :asc) + .map(&:name) + actual_names = json_response['data'].map {|data| + data['attributes']['name'] + } + assert_equal expected_names, actual_names, "since adapter_sorts_nulls_last=#{adapter_sorts_nulls_last}" end def test_fetch_robots_with_sort_by_lower_name diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index b7895608c..4ab186021 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -1443,16 +1443,35 @@ def test_sort_primary_attribute end def test_sort_included_attribute - # Postgres sorts nulls last, whereas sqlite and mysql sort nulls first - pg = ENV['DATABASE_URL'].starts_with?('postgres') - + if is_db?(:mysql) + skip "#{adapter_name} test expectations differ in insignificant ways from expected" + end get '/api/v6/authors?sort=author_detail.author_stuff', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } assert_jsonapi_response 200 - assert_equal pg ? '1001' : '1000', json_response['data'][0]['id'] + up_expected_ids = AuthorResource + ._model_class + .all + .left_joins(:author_detail) + .merge(AuthorDetail.order(author_stuff: :asc)) + .map(&:id) + expected = up_expected_ids.first.to_s + ids = json_response['data'].map {|data| data['id'] } + actual = ids.first + assert_equal expected, actual, "since adapter_sorts_nulls_last=#{adapter_sorts_nulls_last} ands actual=#{ids} vs. expected=#{up_expected_ids}" get '/api/v6/authors?sort=-author_detail.author_stuff', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } assert_jsonapi_response 200 - assert_equal pg ? '1000' : '1002', json_response['data'][0]['id'] + down_expected_ids = AuthorResource + ._model_class + .all + .left_joins(:author_detail) + .merge(AuthorDetail.order(author_stuff: :desc)) + .map(&:id) + expected = down_expected_ids.first.to_s + ids = json_response['data'].map {|data| data['id'] } + actual = ids.first + assert_equal expected, actual, "since adapter_sorts_nulls_last=#{adapter_sorts_nulls_last} ands actual=#{ids} vs. expected=#{down_expected_ids}" + refute_equal up_expected_ids, down_expected_ids # sanity check end def test_include_parameter_quoted diff --git a/test/test_helper.rb b/test/test_helper.rb index 8f19a2577..f90a119db 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -467,6 +467,16 @@ def adapter_name ActiveRecord::Base.connection.adapter_name end + # Postgres sorts nulls last, whereas sqlite and mysql sort nulls first + def adapter_sorts_nulls_last + case adapter_name + when 'PostgreSQL' then true + when 'SQLite', 'Mysql2' then false + else + fail ArgumentError, "Unhandled adapter #{adapter_name} in #{__callee__}" + end + end + def db_quote_identifier case adapter_name when 'SQLite', 'PostgreSQL' @@ -478,6 +488,16 @@ def db_quote_identifier end end + def is_db?(db_name) + case db_name + when :sqlite then /sqlite/i.match?(adapter_name) + when :postgres, :pg then /postgres/i.match?(adapter_name) + when :mysql then /mysql/i.match?(adapter_name) + else + /#{db_name}/i.match?(adapter_name) + end + end + def db_true ActiveRecord::Base.connection.quote(true) end From 92c5fe50482568af09097fd8bc18f38043c15146 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 19 Sep 2023 14:20:57 -0400 Subject: [PATCH 05/34] V0.11 refactor resource classes to modules (#1406) * Restore previous include directives behavior * Default sort use _primary_key * Remove support for pluck attributes * Pass relationship instead of relationship name * Update copyright date * Ignore docker-compose override files * add _relation_name method * Rework resource class to support using modules for retrieving resources by way of a `resource_retrieval_strategy` Removes BasicResource class and replaces ActiveRelationResource with a module * Use `_relationship` helper method * Add ActiveRelationRetrieval Allows retrieval of resources by querying the primary table and joining the source table - the opposite of the v10 version * Skip extra pluck queries when not caching a resource * Test Cleanup * Adjust tested query counts based on default_resource_retrieval_strategy * create_implicit_polymorphic_type_relationships * Add ActiveRelationRetrievalV09 * Move resource down in the load order * Use underscore instead of downcase * Refactor Resource to load retrieval strategy as class loads * Simplify loading resource retrieval strategy modules Add SimpleResource that does not load a resource retrieval strategy module * Remove no longer need deferred_relationship code * Add warning about potentially unused `records_for_populate` * Rework loading the resource_retrieval_strategy to fix issue in real projects * Use SortedSets for resource_identities * Add sorted_set gem * Remove rails 5 support --- .github/workflows/ruby.yml | 14 - .gitignore | 1 + LICENSE.txt | 2 +- README.md | 2 +- jsonapi-resources.gemspec | 2 + lib/jsonapi-resources.rb | 8 +- lib/jsonapi/active_relation/join_manager.rb | 36 +- .../active_relation/join_manager_v10.rb | 297 ++ lib/jsonapi/active_relation_retrieval.rb | 883 ++++ lib/jsonapi/active_relation_retrieval_v09.rb | 713 ++++ ...ce.rb => active_relation_retrieval_v10.rb} | 168 +- lib/jsonapi/configuration.rb | 30 +- lib/jsonapi/include_directives.rb | 94 +- lib/jsonapi/processor.rb | 17 +- lib/jsonapi/relationship.rb | 120 +- lib/jsonapi/resource.rb | 7 +- .../{basic_resource.rb => resource_common.rb} | 247 +- lib/jsonapi/resource_fragment.rb | 13 +- lib/jsonapi/resource_identity.rb | 4 + lib/jsonapi/resource_serializer.rb | 2 +- lib/jsonapi/resource_set.rb | 4 +- lib/jsonapi/resource_tree.rb | 51 +- lib/jsonapi/response_document.rb | 2 +- lib/jsonapi/routing_ext.rb | 4 +- lib/jsonapi/simple_resource.rb | 11 + lib/tasks/check_upgrade.rake | 2 +- test/controllers/controller_test.rb | 3577 +++++++++-------- test/fixtures/active_record.rb | 40 +- test/helpers/assertions.rb | 2 +- test/helpers/configuration_helpers.rb | 11 +- test/integration/requests/request_test.rb | 27 +- test/test_helper.rb | 49 +- .../join_manager_test.rb | 45 +- .../join_manager_v10_test.rb | 222 + .../resource/active_relation_resource_test.rb | 237 -- .../active_relation_resource_v_10_test.rb | 236 ++ .../active_relation_resource_v_11_test.rb | 238 ++ test/unit/resource/relationship_test.rb | 3 +- test/unit/resource/resource_test.rb | 82 +- .../serializer/include_directives_test.rb | 60 +- test/unit/serializer/link_builder_test.rb | 22 +- test/unit/serializer/serializer_test.rb | 1156 +++--- 42 files changed, 5685 insertions(+), 3056 deletions(-) create mode 100644 lib/jsonapi/active_relation/join_manager_v10.rb create mode 100644 lib/jsonapi/active_relation_retrieval.rb create mode 100644 lib/jsonapi/active_relation_retrieval_v09.rb rename lib/jsonapi/{active_relation_resource.rb => active_relation_retrieval_v10.rb} (85%) rename lib/jsonapi/{basic_resource.rb => resource_common.rb} (81%) create mode 100644 lib/jsonapi/simple_resource.rb create mode 100644 test/unit/active_relation_resource_finder/join_manager_v10_test.rb delete mode 100644 test/unit/resource/active_relation_resource_test.rb create mode 100644 test/unit/resource/active_relation_resource_v_10_test.rb create mode 100644 test/unit/resource/active_relation_resource_v_11_test.rb diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index daf2c256a..7534b022a 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -47,8 +47,6 @@ jobs: - '7.0' - '6.1' - '6.0' - - '5.2' - - '5.1' database_url: - sqlite3:test_db - postgresql://postgres:password@localhost:5432/test @@ -56,26 +54,14 @@ jobs: exclude: - ruby: '3.2' rails: '6.0' - - ruby: '3.2' - rails: '5.2' - - ruby: '3.2' - rails: '5.1' - ruby: '3.1' rails: '6.0' - - ruby: '3.1' - rails: '5.2' - ruby: '3.1' rails: '5.1' - ruby: '3.0' rails: '6.0' - - ruby: '3.0' - rails: '5.2' - - ruby: '3.0' - rails: '5.1' - ruby: '2.6' rails: '7.0' - - database_url: postgresql://postgres:password@localhost:5432/test - rails: '5.1' env: RAILS_VERSION: ${{ matrix.rails }} DATABASE_URL: ${{ matrix.database_url }} diff --git a/.gitignore b/.gitignore index 800c71c6a..663f28c51 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ test_db test_db-journal .idea *.iml +*.override.yml diff --git a/LICENSE.txt b/LICENSE.txt index fd20f1555..8cf58a222 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2014-2021 Cerebris Corporation +Copyright (c) 2014-2023 Cerebris Corporation MIT License diff --git a/README.md b/README.md index 1b29259f8..aedeb9d5a 100644 --- a/README.md +++ b/README.md @@ -110,4 +110,4 @@ and **paste the content into the issue description or attach as a file**: ## License -Copyright 2014-2021 Cerebris Corporation. MIT License (see LICENSE for details). +Copyright 2014-2023 Cerebris Corporation. MIT License (see LICENSE for details). diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index eb3c67fa5..2f2044aa8 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -27,7 +27,9 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'pry' spec.add_development_dependency 'concurrent-ruby-ext' spec.add_development_dependency 'database_cleaner' + spec.add_development_dependency 'hashie' spec.add_dependency 'activerecord', '>= 5.1' spec.add_dependency 'railties', '>= 5.1' spec.add_dependency 'concurrent-ruby' + spec.add_dependency 'sorted_set' end diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 401d9bbc7..bf3799685 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -3,9 +3,12 @@ require 'jsonapi/resources/railtie' require 'jsonapi/naive_cache' require 'jsonapi/compiled_json' -require 'jsonapi/basic_resource' -require 'jsonapi/active_relation_resource' +require 'jsonapi/active_relation_retrieval' +require 'jsonapi/active_relation_retrieval_v09' +require 'jsonapi/active_relation_retrieval_v10' +require 'jsonapi/resource_common' require 'jsonapi/resource' +require 'jsonapi/simple_resource' require 'jsonapi/cached_response_fragment' require 'jsonapi/response_document' require 'jsonapi/acts_as_resource_controller' @@ -37,6 +40,7 @@ require 'jsonapi/link_builder' require 'jsonapi/active_relation/adapters/join_left_active_record_adapter' require 'jsonapi/active_relation/join_manager' +require 'jsonapi/active_relation/join_manager_v10' require 'jsonapi/resource_identity' require 'jsonapi/resource_fragment' require 'jsonapi/resource_tree' diff --git a/lib/jsonapi/active_relation/join_manager.rb b/lib/jsonapi/active_relation/join_manager.rb index c41d4a7f6..eaacf52e0 100644 --- a/lib/jsonapi/active_relation/join_manager.rb +++ b/lib/jsonapi/active_relation/join_manager.rb @@ -7,17 +7,22 @@ class JoinManager attr_reader :resource_klass, :source_relationship, :resource_join_tree, - :join_details + :join_details, + :through_source def initialize(resource_klass:, source_relationship: nil, + source_resource_klass: nil, + through_source: false, relationships: nil, filters: nil, sort_criteria: nil) @resource_klass = resource_klass + @source_resource_klass = source_resource_klass @join_details = nil @collected_aliases = Set.new + @through_source = through_source @resource_join_tree = { root: { @@ -45,7 +50,7 @@ def join(records, options) # this method gets the join details whether they are on a relationship or are just pseudo details for the base # resource. Specify the resource type for polymorphic relationships # - def source_join_details(type=nil) + def source_join_details(type = nil) if source_relationship related_resource_klass = type ? resource_klass.resource_klass_for(type) : source_relationship.resource_klass segment = PathSegment::Relationship.new(relationship: source_relationship, resource_klass: related_resource_klass) @@ -108,14 +113,20 @@ def self.get_join_arel_node(records, relationship, join_type, options = {}) end def self.alias_from_arel_node(node) - case node.left + # case node.left + case node&.left when Arel::Table node.left.name when Arel::Nodes::TableAlias node.left.right when Arel::Nodes::StringJoin # :nocov: - warn "alias_from_arel_node: Unsupported join type - use custom filtering and sorting" + warn "alias_from_arel_node: Unsupported join type `Arel::Nodes::StringJoin` - use custom filtering and sorting" + nil + # :nocov: + else + # :nocov: + warn "alias_from_arel_node: Unsupported join type `#{node&.left.to_s}`" nil # :nocov: end @@ -181,7 +192,8 @@ def perform_joins(records, options) options: options) } - details = {alias: self.class.alias_from_arel_node(join_node), join_type: join_type} + join_alias = self.class.alias_from_arel_node(join_node) + details = {alias: join_alias, join_type: join_type} if relationship == source_relationship if relationship.polymorphic? && relationship.belongs_to? @@ -193,15 +205,19 @@ def perform_joins(records, options) # We're adding the source alias with two keys. We only want the check for duplicate aliases once. # See the note in `add_join_details`. - check_for_duplicate_alias = !(relationship == source_relationship) - add_join_details(PathSegment::Relationship.new(relationship: relationship, resource_klass: related_resource_klass), details, check_for_duplicate_alias) + check_for_duplicate_alias = relationship != source_relationship + path_segment = PathSegment::Relationship.new(relationship: relationship, + resource_klass: related_resource_klass) + + add_join_details(path_segment, details, check_for_duplicate_alias) end end records end def add_join(path, default_type = :inner, default_polymorphic_join_type = :left) - if source_relationship + # puts "add_join #{path} default_type=#{default_type} default_polymorphic_join_type=#{default_polymorphic_join_type}" + if source_relationship && through_source if source_relationship.polymorphic? # Polymorphic paths will come it with the resource_type as the first segment (for example `#documents.comments`) # We just need to prepend the relationship portion the @@ -213,9 +229,9 @@ def add_join(path, default_type = :inner, default_polymorphic_join_type = :left) sourced_path = path end - join_manager, _field = parse_path_to_tree(sourced_path, resource_klass, default_type, default_polymorphic_join_type) + join_tree, _field = parse_path_to_tree(sourced_path, resource_klass, default_type, default_polymorphic_join_type) - @resource_join_tree[:root].deep_merge!(join_manager) { |key, val, other_val| + @resource_join_tree[:root].deep_merge!(join_tree) { |key, val, other_val| if key == :join_type if val == other_val val diff --git a/lib/jsonapi/active_relation/join_manager_v10.rb b/lib/jsonapi/active_relation/join_manager_v10.rb new file mode 100644 index 000000000..1fc96cc1d --- /dev/null +++ b/lib/jsonapi/active_relation/join_manager_v10.rb @@ -0,0 +1,297 @@ +module JSONAPI + module ActiveRelation + + # Stores relationship paths starting from the resource_klass, consolidating duplicate paths from + # relationships, filters and sorts. When joins are made the table aliases are tracked in join_details + class JoinManagerV10 + attr_reader :resource_klass, + :source_relationship, + :resource_join_tree, + :join_details + + def initialize(resource_klass:, + source_relationship: nil, + relationships: nil, + filters: nil, + sort_criteria: nil) + + @resource_klass = resource_klass + @join_details = nil + @collected_aliases = Set.new + + @resource_join_tree = { + root: { + join_type: :root, + resource_klasses: { + resource_klass => { + relationships: {} + } + } + } + } + add_source_relationship(source_relationship) + add_sort_criteria(sort_criteria) + add_filters(filters) + add_relationships(relationships) + end + + def join(records, options) + fail "can't be joined again" if @join_details + @join_details = {} + perform_joins(records, options) + end + + # source details will only be on a relationship if the source_relationship is set + # this method gets the join details whether they are on a relationship or are just pseudo details for the base + # resource. Specify the resource type for polymorphic relationships + # + def source_join_details(type=nil) + if source_relationship + related_resource_klass = type ? resource_klass.resource_klass_for(type) : source_relationship.resource_klass + segment = PathSegment::Relationship.new(relationship: source_relationship, resource_klass: related_resource_klass) + details = @join_details[segment] + else + if type + details = @join_details["##{type}"] + else + details = @join_details[''] + end + end + details + end + + def join_details_by_polymorphic_relationship(relationship, type) + segment = PathSegment::Relationship.new(relationship: relationship, resource_klass: resource_klass.resource_klass_for(type)) + @join_details[segment] + end + + def join_details_by_relationship(relationship) + segment = PathSegment::Relationship.new(relationship: relationship, resource_klass: relationship.resource_klass) + @join_details[segment] + end + + def self.get_join_arel_node(records, options = {}) + init_join_sources = records.arel.join_sources + init_join_sources_length = init_join_sources.length + + records = yield(records, options) + + join_sources = records.arel.join_sources + if join_sources.length > init_join_sources_length + last_join = (join_sources - init_join_sources).last + else + # :nocov: + warn "get_join_arel_node: No join added" + last_join = nil + # :nocov: + end + + return records, last_join + end + + def self.alias_from_arel_node(node) + case node.left + when Arel::Table + node.left.name + when Arel::Nodes::TableAlias + node.left.right + when Arel::Nodes::StringJoin + # :nocov: + warn "alias_from_arel_node: Unsupported join type - use custom filtering and sorting" + nil + # :nocov: + end + end + + private + + def flatten_join_tree_by_depth(join_array = [], node = @resource_join_tree, level = 0) + join_array[level] = [] unless join_array[level] + + node.each do |relationship, relationship_details| + relationship_details[:resource_klasses].each do |related_resource_klass, resource_details| + join_array[level] << { relationship: relationship, + relationship_details: relationship_details, + related_resource_klass: related_resource_klass} + flatten_join_tree_by_depth(join_array, resource_details[:relationships], level+1) + end + end + join_array + end + + def add_join_details(join_key, details, check_for_duplicate_alias = true) + fail "details already set" if @join_details.has_key?(join_key) + @join_details[join_key] = details + + # Joins are being tracked as they are added to the built up relation. If the same table is added to a + # relation more than once subsequent versions will be assigned an alias. Depending on the order the joins + # are made the computed aliases may change. The order this library performs the joins was chosen + # to prevent this. However if the relation is reordered it should result in reusing on of the earlier + # aliases (in this case a plain table name). The following check will catch this an raise an exception. + # An exception is appropriate because not using the correct alias could leak data due to filters and + # applied permissions being performed on the wrong data. + if check_for_duplicate_alias && @collected_aliases.include?(details[:alias]) + fail "alias '#{details[:alias]}' has already been added. Possible relation reordering" + end + + @collected_aliases << details[:alias] + end + + def perform_joins(records, options) + join_array = flatten_join_tree_by_depth + + join_array.each do |level_joins| + level_joins.each do |join_details| + relationship = join_details[:relationship] + relationship_details = join_details[:relationship_details] + related_resource_klass = join_details[:related_resource_klass] + join_type = relationship_details[:join_type] + + if relationship == :root + unless source_relationship + add_join_details('', {alias: resource_klass._table_name, join_type: :root}) + end + next + end + + records, join_node = self.class.get_join_arel_node(records, options) {|records, options| + related_resource_klass.join_relationship( + records: records, + resource_type: related_resource_klass._type, + join_type: join_type, + relationship: relationship, + options: options) + } + + details = {alias: self.class.alias_from_arel_node(join_node), join_type: join_type} + + if relationship == source_relationship + if relationship.polymorphic? && relationship.belongs_to? + add_join_details("##{related_resource_klass._type}", details) + else + add_join_details('', details) + end + end + + # We're adding the source alias with two keys. We only want the check for duplicate aliases once. + # See the note in `add_join_details`. + check_for_duplicate_alias = !(relationship == source_relationship) + add_join_details(PathSegment::Relationship.new(relationship: relationship, resource_klass: related_resource_klass), details, check_for_duplicate_alias) + end + end + records + end + + def add_join(path, default_type = :inner, default_polymorphic_join_type = :left) + if source_relationship + if source_relationship.polymorphic? + # Polymorphic paths will come it with the resource_type as the first segment (for example `#documents.comments`) + # We just need to prepend the relationship portion the + sourced_path = "#{source_relationship.name}#{path}" + else + sourced_path = "#{source_relationship.name}.#{path}" + end + else + sourced_path = path + end + + join_manager, _field = parse_path_to_tree(sourced_path, resource_klass, default_type, default_polymorphic_join_type) + + @resource_join_tree[:root].deep_merge!(join_manager) { |key, val, other_val| + if key == :join_type + if val == other_val + val + else + :inner + end + end + } + end + + def process_path_to_tree(path_segments, resource_klass, default_join_type, default_polymorphic_join_type) + node = { + resource_klasses: { + resource_klass => { + relationships: {} + } + } + } + + segment = path_segments.shift + + if segment.is_a?(PathSegment::Relationship) + node[:resource_klasses][resource_klass][:relationships][segment.relationship] ||= {} + + # join polymorphic as left joins + node[:resource_klasses][resource_klass][:relationships][segment.relationship][:join_type] ||= + segment.relationship.polymorphic? ? default_polymorphic_join_type : default_join_type + + segment.relationship.resource_types.each do |related_resource_type| + related_resource_klass = resource_klass.resource_klass_for(related_resource_type) + + # If the resource type was specified in the path segment we want to only process the next segments for + # that resource type, otherwise process for all + process_all_types = !segment.path_specified_resource_klass? + + if process_all_types || related_resource_klass == segment.resource_klass + related_resource_tree = process_path_to_tree(path_segments.dup, related_resource_klass, default_join_type, default_polymorphic_join_type) + node[:resource_klasses][resource_klass][:relationships][segment.relationship].deep_merge!(related_resource_tree) + end + end + end + node + end + + def parse_path_to_tree(path_string, resource_klass, default_join_type = :inner, default_polymorphic_join_type = :left) + path = JSONAPI::Path.new(resource_klass: resource_klass, path_string: path_string) + + field = path.segments[-1] + return process_path_to_tree(path.segments, resource_klass, default_join_type, default_polymorphic_join_type), field + end + + def add_source_relationship(source_relationship) + @source_relationship = source_relationship + + if @source_relationship + resource_klasses = {} + source_relationship.resource_types.each do |related_resource_type| + related_resource_klass = resource_klass.resource_klass_for(related_resource_type) + resource_klasses[related_resource_klass] = {relationships: {}} + end + + join_type = source_relationship.polymorphic? ? :left : :inner + + @resource_join_tree[:root][:resource_klasses][resource_klass][:relationships][@source_relationship] = { + source: true, resource_klasses: resource_klasses, join_type: join_type + } + end + end + + def add_filters(filters) + return if filters.blank? + filters.each_key do |filter| + # Do not add joins for filters with an apply callable. This can be overridden by setting perform_joins to true + next if resource_klass._allowed_filters[filter].try(:[], :apply) && + !resource_klass._allowed_filters[filter].try(:[], :perform_joins) + + add_join(filter, :left) + end + end + + def add_sort_criteria(sort_criteria) + return if sort_criteria.blank? + + sort_criteria.each do |sort| + add_join(sort[:field], :left) + end + end + + def add_relationships(relationships) + return if relationships.blank? + relationships.each do |relationship| + add_join(relationship, :left) + end + end + end + end +end diff --git a/lib/jsonapi/active_relation_retrieval.rb b/lib/jsonapi/active_relation_retrieval.rb new file mode 100644 index 000000000..23758352e --- /dev/null +++ b/lib/jsonapi/active_relation_retrieval.rb @@ -0,0 +1,883 @@ +module JSONAPI + module ActiveRelationRetrieval + def find_related_ids(relationship, options = {}) + self.class.find_related_fragments(self, relationship, options).keys.collect { |rid| rid.id } + end + + module ClassMethods + # Finds Resources using the `filters`. Pagination and sort options are used when provided + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Hash] :sort_criteria The `sort criteria` + # @option options [Hash] :include_directives The `include_directives` + # + # @return [Array] the Resource instances matching the filters, sorting and pagination rules. + def find(filters, options = {}) + sort_criteria = options.fetch(:sort_criteria) { [] } + + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + filters: filters, + sort_criteria: sort_criteria) + + paginator = options[:paginator] + + records = apply_request_settings_to_records(records: records(options), + sort_criteria: sort_criteria,filters: filters, + join_manager: join_manager, + paginator: paginator, + options: options) + + resources_for(records, options[:context]) + end + + # Counts Resources found using the `filters` + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [Integer] the count + def count(filters, options = {}) + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + filters: filters) + + records = apply_request_settings_to_records(records: records(options), + filters: filters, + join_manager: join_manager, + options: options) + + count_records(records) + end + + # Returns the single Resource identified by `key` + # + # @param key the primary key of the resource to find + # @option options [Hash] :context The context of the request, set in the controller + def find_by_key(key, options = {}) + record = find_record_by_key(key, options) + resource_for(record, options[:context]) + end + + # Returns an array of Resources identified by the `keys` array + # + # @param keys [Array] Array of primary keys to find resources for + # @option options [Hash] :context The context of the request, set in the controller + def find_by_keys(keys, options = {}) + records = find_records_by_keys(keys, options) + resources_for(records, options[:context]) + end + + # Returns an array of Resources identified by the `keys` array. The resources are not filtered as this + # will have been done in a prior step + # + # @param keys [Array] Array of primary keys to find resources for + # @option options [Hash] :context The context of the request, set in the controller + def find_to_populate_by_keys(keys, options = {}) + records = records_for_populate(options).where(_primary_key => keys) + resources_for(records, options[:context]) + end + + # Finds Resource fragments using the `filters`. Pagination and sort options are used when provided. + # Note: This is incompatible with Polymorphic resources (which are going to come from two separate tables) + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Hash] :sort_criteria The `sort criteria` + # @option options [Hash] :include_directives The `include_directives` + # @option options [Boolean] :cache Return the resources' cache field + # + # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field}] + # the ResourceInstances matching the filters, sorting, and pagination rules along with any request + # additional_field values + def find_fragments(filters, options = {}) + include_directives = options.fetch(:include_directives, {}) + resource_klass = self + + fragments = {} + + linkage_relationships = to_one_relationships_for_linkage(include_directives[:include_related]) + + sort_criteria = options.fetch(:sort_criteria) { [] } + + join_manager = ActiveRelation::JoinManager.new(resource_klass: resource_klass, + source_relationship: nil, + relationships: linkage_relationships.collect(&:name), + sort_criteria: sort_criteria, + filters: filters) + + paginator = options[:paginator] + + records = apply_request_settings_to_records(records: records(options), + filters: filters, + sort_criteria: sort_criteria, + paginator: paginator, + join_manager: join_manager, + options: options) + + if options[:cache] + # This alias is going to be resolve down to the model's table name and will not actually be an alias + resource_table_alias = resource_klass._table_name + + pluck_fields = [sql_field_with_alias(resource_table_alias, resource_klass._primary_key)] + + cache_field = attribute_to_model_field(:_cache_field) + pluck_fields << sql_field_with_alias(resource_table_alias, cache_field[:name]) + + linkage_fields = [] + + linkage_relationships.each do |linkage_relationship| + linkage_relationship_name = linkage_relationship.name + + if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? + linkage_relationship.resource_types.each do |resource_type| + klass = resource_klass_for(resource_type) + linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] + primary_key = klass._primary_key + + linkage_fields << {relationship_name: linkage_relationship_name, + resource_klass: klass, + field: sql_field_with_alias(linkage_table_alias, primary_key), + alias: alias_table_field(linkage_table_alias, primary_key)} + + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + else + klass = linkage_relationship.resource_klass + linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] + fail "Missing linkage_table_alias for #{linkage_relationship}" unless linkage_table_alias + primary_key = klass._primary_key + + linkage_fields << {relationship_name: linkage_relationship_name, + resource_klass: klass, + field: sql_field_with_alias(linkage_table_alias, primary_key), + alias: alias_table_field(linkage_table_alias, primary_key)} + + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + end + + sort_fields = options.dig(:_relation_helper_options, :sort_fields) + sort_fields.try(:each) do |field| + pluck_fields << Arel.sql(field) + end + + rows = records.pluck(*pluck_fields) + rows.each do |row| + rid = JSONAPI::ResourceIdentity.new(resource_klass, pluck_fields.length == 1 ? row : row[0]) + + fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) + attributes_offset = 2 + + fragments[rid].cache = cast_to_attribute_type(row[1], cache_field[:type]) + + linkage_fields.each do |linkage_field_details| + fragments[rid].initialize_related(linkage_field_details[:relationship_name]) + related_id = row[attributes_offset] + if related_id + related_rid = JSONAPI::ResourceIdentity.new(linkage_field_details[:resource_klass], related_id) + fragments[rid].add_related_identity(linkage_field_details[:relationship_name], related_rid) + end + attributes_offset+= 1 + end + end + + if JSONAPI.configuration.warn_on_performance_issues && (rows.length > fragments.length) + warn "Performance issue detected: `#{self.name.to_s}.records` returned non-normalized results in `#{self.name.to_s}.find_fragments`." + end + else + linkage_fields = [] + + linkage_relationships.each do |linkage_relationship| + linkage_relationship_name = linkage_relationship.name + + if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? + linkage_relationship.resource_types.each do |resource_type| + klass = resource_klass_for(resource_type) + linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] + primary_key = klass._primary_key + + select_alias = "jr_l_#{linkage_relationship_name}_#{resource_type}_pk" + select_alias_statement = sql_field_with_fixed_alias(linkage_table_alias, primary_key, select_alias) + + linkage_fields << {relationship_name: linkage_relationship_name, + resource_klass: klass, + select: select_alias_statement, + select_alias: select_alias} + end + else + klass = linkage_relationship.resource_klass + linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] + fail "Missing linkage_table_alias for #{linkage_relationship}" unless linkage_table_alias + primary_key = klass._primary_key + + select_alias = "jr_l_#{linkage_relationship_name}_pk" + select_alias_statement = sql_field_with_fixed_alias(linkage_table_alias, primary_key, select_alias) + linkage_fields << {relationship_name: linkage_relationship_name, + resource_klass: klass, + select: select_alias_statement, + select_alias: select_alias} + end + end + + + if linkage_fields.any? + records = records.select(linkage_fields.collect {|f| f[:select]}) + end + + records = records.select(concat_table_field(_table_name, Arel.star)) + resources = resources_for(records, options[:context]) + + resources.each do |resource| + rid = resource.identity + fragments[rid] ||= JSONAPI::ResourceFragment.new(rid, resource: resource) + + linkage_fields.each do |linkage_field_details| + fragments[rid].initialize_related(linkage_field_details[:relationship_name]) + related_id = resource._model.attributes[linkage_field_details[:select_alias]] + if related_id + related_rid = JSONAPI::ResourceIdentity.new(linkage_field_details[:resource_klass], related_id) + fragments[rid].add_related_identity(linkage_field_details[:relationship_name], related_rid) + end + end + end + end + + fragments + end + + # Finds Resource Fragments related to the source resources through the specified relationship + # + # @param source_rids [Array] The resources to find related ResourcesIdentities for + # @param relationship_name [String | Symbol] The name of the relationship + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Boolean] :cache Return the resources' cache field + # + # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field, related: {relationship_name: [] }}}] + # the ResourceInstances matching the filters, sorting, and pagination rules along with any request + # additional_field values + def find_related_fragments(source_fragment, relationship, options = {}) + if relationship.polymorphic? # && relationship.foreign_key_on == :self + source_resource_klasses = if relationship.foreign_key_on == :self + relationship.class.polymorphic_types(relationship.name).collect do |polymorphic_type| + resource_klass_for(polymorphic_type) + end + else + source.collect { |fragment| fragment.identity.resource_klass }.to_set + end + + fragments = {} + source_resource_klasses.each do |resource_klass| + inverse_direct_relationship = _relationship(resource_klass._type.to_s.singularize) + + fragments.merge!(resource_klass.find_related_fragments_from_inverse([source_fragment], inverse_direct_relationship, options, true)) + end + fragments + else + relationship.resource_klass.find_related_fragments_from_inverse([source_fragment], relationship, options, false) + end + end + + def find_included_fragments(source_fragments, relationship, options) + if relationship.polymorphic? # && relationship.foreign_key_on == :self + source_resource_klasses = if relationship.foreign_key_on == :self + relationship.class.polymorphic_types(relationship.name).collect do |polymorphic_type| + resource_klass_for(polymorphic_type) + end + else + source_fragments.collect { |fragment| fragment.identity.resource_klass }.to_set + end + + fragments = {} + source_resource_klasses.each do |resource_klass| + inverse_direct_relationship = _relationship(resource_klass._type.to_s.singularize) + + fragments.merge!(resource_klass.find_related_fragments_from_inverse(source_fragments, inverse_direct_relationship, options, true)) + end + fragments + else + relationship.resource_klass.find_related_fragments_from_inverse(source_fragments, relationship, options, true) + end + end + + def find_related_fragments_from_inverse(source, source_relationship, options, connect_source_identity) + relationship = source_relationship.resource_klass._relationship(source_relationship.inverse_relationship) + raise "missing inverse relationship" unless relationship.present? + + parent_resource_klass = relationship.resource_klass + + include_directives = options.fetch(:include_directives, {}) + + # ToDo: Handle resources vs identities + source_ids = source.collect {|item| item.identity.id} + + filters = options.fetch(:filters, {}) + + linkage_relationships = to_one_relationships_for_linkage(include_directives[:include_related]) + + sort_criteria = [] + options[:sort_criteria].try(:each) do |sort| + field = sort[:field].to_s == 'id' ? _primary_key : sort[:field] + sort_criteria << { field: field, direction: sort[:direction] } + end + + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + source_relationship: relationship, + relationships: linkage_relationships.collect(&:name), + sort_criteria: sort_criteria, + filters: filters) + + paginator = options[:paginator] + + records = apply_request_settings_to_records(records: records(options), + sort_criteria: sort_criteria, + source_ids: source_ids, + paginator: paginator, + filters: filters, + join_manager: join_manager, + options: options) + + fragments = {} + + if options[:cache] + # This alias is going to be resolve down to the model's table name and will not actually be an alias + resource_table_alias = self._table_name + parent_table_alias = join_manager.join_details_by_relationship(relationship)[:alias] + + pluck_fields = [ + sql_field_with_alias(resource_table_alias, self._primary_key), + sql_field_with_alias(parent_table_alias, parent_resource_klass._primary_key) + ] + + cache_field = attribute_to_model_field(:_cache_field) + pluck_fields << sql_field_with_alias(resource_table_alias, cache_field[:name]) + + linkage_fields = [] + + linkage_relationships.each do |linkage_relationship| + linkage_relationship_name = linkage_relationship.name + + if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? + linkage_relationship.resource_types.each do |resource_type| + klass = resource_klass_for(resource_type) + linkage_fields << {relationship_name: linkage_relationship_name, resource_klass: klass} + + linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] + primary_key = klass._primary_key + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + else + klass = linkage_relationship.resource_klass + linkage_fields << {relationship_name: linkage_relationship_name, resource_klass: klass} + + linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] + primary_key = klass._primary_key + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + end + + sort_fields = options.dig(:_relation_helper_options, :sort_fields) + sort_fields.try(:each) do |field| + pluck_fields << Arel.sql(field) + end + + rows = records.distinct.pluck(*pluck_fields) + rows.each do |row| + rid = JSONAPI::ResourceIdentity.new(self, row[0]) + fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) + + parent_rid = JSONAPI::ResourceIdentity.new(parent_resource_klass, row[1]) + fragments[rid].add_related_from(parent_rid) + + if connect_source_identity + fragments[rid].add_related_identity(relationship.name, parent_rid) + end + + attributes_offset = 2 + fragments[rid].cache = cast_to_attribute_type(row[attributes_offset], cache_field[:type]) + + attributes_offset += 1 + + linkage_fields.each do |linkage_field| + fragments[rid].initialize_related(linkage_field[:relationship_name]) + related_id = row[attributes_offset] + if related_id + related_rid = JSONAPI::ResourceIdentity.new(linkage_field[:resource_klass], related_id) + fragments[rid].add_related_identity(linkage_field[:relationship_name], related_rid) + end + attributes_offset += 1 + end + end + else + linkage_fields = [] + + linkage_relationships.each do |linkage_relationship| + linkage_relationship_name = linkage_relationship.name + + if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? + linkage_relationship.resource_types.each do |resource_type| + klass = linkage_relationship.resource_klass.resource_klass_for(resource_type) + primary_key = klass._primary_key + linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] + + select_alias = "jr_l_#{linkage_relationship_name}_#{resource_type}_pk" + select_alias_statement = sql_field_with_fixed_alias(linkage_table_alias, primary_key, select_alias) + linkage_fields << {relationship_name: linkage_relationship_name, + resource_klass: klass, + select: select_alias_statement, + select_alias: select_alias} + end + else + klass = linkage_relationship.resource_klass + primary_key = klass._primary_key + linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] + select_alias = "jr_l_#{linkage_relationship_name}_pk" + select_alias_statement = sql_field_with_fixed_alias(linkage_table_alias, primary_key, select_alias) + + + linkage_fields << {relationship_name: linkage_relationship_name, + resource_klass: klass, + select: select_alias_statement, + select_alias: select_alias} + end + end + + parent_table_alias = join_manager.join_details_by_relationship(relationship)[:alias] + source_field = sql_field_with_fixed_alias(parent_table_alias, parent_resource_klass._primary_key, "jr_source_id") + + records = records.select(concat_table_field(_table_name, Arel.star), source_field) + + if linkage_fields.any? + records = records.select(linkage_fields.collect {|f| f[:select]}) + end + + resources = resources_for(records, options[:context]) + + resources.each do |resource| + rid = resource.identity + + fragments[rid] ||= JSONAPI::ResourceFragment.new(rid, resource: resource) + + parent_rid = JSONAPI::ResourceIdentity.new(parent_resource_klass, resource._model.attributes['jr_source_id']) + + if connect_source_identity + fragments[rid].add_related_identity(relationship.name, parent_rid) + end + + fragments[rid].add_related_from(parent_rid) + + linkage_fields.each do |linkage_field_details| + fragments[rid].initialize_related(linkage_field_details[:relationship_name]) + related_id = resource._model.attributes[linkage_field_details[:select_alias]] + if related_id + related_rid = JSONAPI::ResourceIdentity.new(linkage_field_details[:resource_klass], related_id) + fragments[rid].add_related_identity(linkage_field_details[:relationship_name], related_rid) + end + end + end + end + + fragments + end + + # Counts Resources related to the source resource through the specified relationship + # + # @param source_rid [ResourceIdentity] Source resource identifier + # @param relationship_name [String | Symbol] The name of the relationship + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [Integer] the count + + def count_related(source, relationship, options = {}) + relationship.resource_klass.count_related_from_inverse(source, relationship, options) + end + + def count_related_from_inverse(source_resource, source_relationship, options = {}) + relationship = source_relationship.resource_klass._relationship(source_relationship.inverse_relationship) + + related_klass = relationship.resource_klass + + filters = options.fetch(:filters, {}) + + # Joins in this case are related to the related_klass + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + source_relationship: relationship, + filters: filters) + + records = apply_request_settings_to_records(records: records(options), + resource_klass: self, + source_ids: source_resource.id, + join_manager: join_manager, + filters: filters, + options: options) + + related_alias = join_manager.join_details_by_relationship(relationship)[:alias] + + records = records.select(Arel.sql("#{concat_table_field(related_alias, related_klass._primary_key)}")) + + count_records(records) + end + + # This resource class (ActiveRelationResource) uses an `ActiveRecord::Relation` as the starting point for + # retrieving models. From this relation filters, sorts and joins are applied as needed. + # Depending on which phase of the request processing different `records` methods will be called, giving the user + # the opportunity to override them differently for performance and security reasons. + + # begin `records`methods + + # Base for the `records` methods that follow and is not directly used for accessing model data by this class. + # Overriding this method gives a single place to affect the `ActiveRecord::Relation` used for the resource. + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records_base(_options = {}) + _model_class.all + end + + # The `ActiveRecord::Relation` used for finding user requested models. This may be overridden to enforce + # permissions checks on the request. + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records(options = {}) + records_base(options) + end + + # The `ActiveRecord::Relation` used for populating the ResourceSet. Only resources that have been previously + # identified through the `records` method will be accessed. Thus it should not be necessary to reapply permissions + # checks. However if the model needs to include other models adding `includes` is appropriate + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records_for_populate(options = {}) + records_base(options) + end + + # The `ActiveRecord::Relation` used for the finding related resources. + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records_for_source_to_related(options = {}) + records_base(options) + end + + # end `records` methods + + def apply_join(records:, relationship:, resource_type:, join_type:, options:) + if relationship.polymorphic? && relationship.belongs_to? + case join_type + when :inner + records = records.joins(resource_type.to_s.singularize.to_sym) + when :left + records = records.joins_left(resource_type.to_s.singularize.to_sym) + end + else + relation_name = relationship.relation_name(options) + + # if relationship.alias_on_join + # alias_name = "#{relationship.preferred_alias}_#{relation_name}" + # case join_type + # when :inner + # records = records.joins_with_alias(relation_name, alias_name) + # when :left + # records = records.left_joins_with_alias(relation_name, alias_name) + # end + # else + case join_type + when :inner + records = records.joins(relation_name) + when :left + records = records.left_joins(relation_name) + end + end + # end + records + end + + def relationship_records(relationship:, join_type: :inner, resource_type: nil, options: {}) + records = relationship.parent_resource.records_for_source_to_related(options) + strategy = relationship.options[:apply_join] + + if strategy + records = call_method_or_proc(strategy, records, relationship, resource_type, join_type, options) + else + records = apply_join(records: records, + relationship: relationship, + resource_type: resource_type, + join_type: join_type, + options: options) + end + + records + end + + def join_relationship(records:, relationship:, resource_type: nil, join_type: :inner, options: {}) + relationship_records = relationship_records(relationship: relationship, + join_type: join_type, + resource_type: resource_type, + options: options) + records.merge(relationship_records) + end + + + # protected + + def find_record_by_key(key, options = {}) + record = apply_request_settings_to_records(records: records(options), primary_keys: key, options: options).first + fail JSONAPI::Exceptions::RecordNotFound.new(key) if record.nil? + record + end + + def find_records_by_keys(keys, options = {}) + apply_request_settings_to_records(records: records(options), primary_keys: keys, options: options) + end + + def apply_request_settings_to_records(records:, + join_manager: ActiveRelation::JoinManager.new(resource_klass: self), + resource_klass: self, + source_ids: nil, + filters: {}, + primary_keys: nil, + sort_criteria: nil, + sort_primary: nil, + paginator: nil, + options: {}) + + options[:_relation_helper_options] = { join_manager: join_manager, sort_fields: [] } + + records = resource_klass.apply_joins(records, join_manager, options) + + if source_ids + source_join_details = join_manager.source_join_details + source_primary_key = join_manager.source_relationship.resource_klass._primary_key + + source_aliased_key = concat_table_field(source_join_details[:alias], source_primary_key, false) + records = records.where(source_aliased_key => source_ids) + end + + if primary_keys + records = records.where(_primary_key => primary_keys) + end + + unless filters.empty? + records = resource_klass.filter_records(records, filters, options) + end + + if sort_primary + records = records.order(_primary_key => :asc) + else + order_options = resource_klass.construct_order_options(sort_criteria) + records = resource_klass.sort_records(records, order_options, options) + end + + if paginator + records = resource_klass.apply_pagination(records, paginator, order_options) + end + + records + end + + def apply_joins(records, join_manager, options) + join_manager.join(records, options) + end + + def apply_pagination(records, paginator, order_options) + records = paginator.apply(records, order_options) if paginator + records + end + + def apply_sort(records, order_options, options) + if order_options.any? + order_options.each_pair do |field, direction| + records = apply_single_sort(records, field, direction, options) + end + end + + records + end + + def apply_single_sort(records, field, direction, options) + context = options[:context] + + strategy = _allowed_sort.fetch(field.to_sym, {})[:apply] + + options[:_relation_helper_options] ||= {} + options[:_relation_helper_options][:sort_fields] ||= [] + + if strategy + records = call_method_or_proc(strategy, records, direction, context) + else + join_manager = options.dig(:_relation_helper_options, :join_manager) + sort_field = join_manager ? get_aliased_field(field, join_manager) : field + options[:_relation_helper_options][:sort_fields].push("#{sort_field}") + records = records.order(Arel.sql("#{sort_field} #{direction}")) + end + records + end + + # Assumes ActiveRecord's counting. Override if you need a different counting method + def count_records(records) + if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR == 5 && ActiveRecord::VERSION::MINOR >= 1) + records.count(:all) + else + records.count + end + end + + def filter_records(records, filters, options) + if _polymorphic + _polymorphic_resource_klasses.each do |klass| + records = klass.apply_filters(records, filters, options) + end + else + records = apply_filters(records, filters, options) + end + records + end + + def construct_order_options(sort_params) + if _polymorphic + warn "Sorting is not supported on polymorphic relationships" + else + super(sort_params) + end + end + + def sort_records(records, order_options, options) + apply_sort(records, order_options, options) + end + + def sql_field_with_alias(table, field, quoted = true) + Arel.sql("#{concat_table_field(table, field, quoted)} AS #{alias_table_field(table, field, quoted)}") + end + + def sql_field_with_fixed_alias(table, field, alias_as, quoted = true) + Arel.sql("#{concat_table_field(table, field, quoted)} AS #{alias_as}") + end + + def concat_table_field(table, field, quoted = false) + if table.blank? + split_table, split_field = field.to_s.split('.') + if split_table && split_field + table = split_table + field = split_field + end + end + if table.blank? + # :nocov: + if quoted + quote_column_name(field) + else + field.to_s + end + # :nocov: + else + if quoted + "#{quote_table_name(table)}.#{quote_column_name(field)}" + else + # :nocov: + "#{table.to_s}.#{field.to_s}" + # :nocov: + end + end + end + + def alias_table_field(table, field, quoted = false) + if table.blank? || field.to_s.include?('.') + # :nocov: + if quoted + quote_column_name(field) + else + field.to_s + end + # :nocov: + else + if quoted + # :nocov: + quote_column_name("#{table.to_s}_#{field.to_s}") + # :nocov: + else + "#{table.to_s}_#{field.to_s}" + end + end + end + + def quote_table_name(table_name) + if _model_class&.connection + _model_class.connection.quote_table_name(table_name) + else + quote(table_name) + end + end + + def quote_column_name(column_name) + return column_name if column_name == "*" + if _model_class&.connection + _model_class.connection.quote_column_name(column_name) + else + quote(column_name) + end + end + + # fallback quote identifier when database adapter not available + def quote(field) + %{"#{field.to_s}"} + end + + def apply_filters(records, filters, options = {}) + if filters + filters.each do |filter, value| + records = apply_filter(records, filter, value, options) + end + end + + records + end + + def get_aliased_field(path_with_field, join_manager) + path = JSONAPI::Path.new(resource_klass: self, path_string: path_with_field) + + relationship_segment = path.segments[-2] + field_segment = path.segments[-1] + + if relationship_segment + join_details = join_manager.join_details[path.last_relationship] + table_alias = join_details[:alias] + else + table_alias = self._table_name + end + + concat_table_field(table_alias, field_segment.delegated_field_name) + end + + def apply_filter(records, filter, value, options = {}) + strategy = _allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] + + if strategy + records = call_method_or_proc(strategy, records, value, options) + else + join_manager = options.dig(:_relation_helper_options, :join_manager) + field = join_manager ? get_aliased_field(filter, join_manager) : filter.to_s + records = records.where(Arel.sql(field) => value) + end + + records + end + + def warn_about_unused_methods + if Rails.env.development? + if !caching? && implements_class_method?(:records_for_populate) + warn "#{self}: The `records_for_populate` method is not used when caching is disabled." + end + end + end + + def implements_class_method?(method_name) + methods(false).include?(method_name) + end + end + end +end diff --git a/lib/jsonapi/active_relation_retrieval_v09.rb b/lib/jsonapi/active_relation_retrieval_v09.rb new file mode 100644 index 000000000..ef5fafaeb --- /dev/null +++ b/lib/jsonapi/active_relation_retrieval_v09.rb @@ -0,0 +1,713 @@ +module JSONAPI + module ActiveRelationRetrievalV09 + def find_related_ids(relationship, options = {}) + self.class.find_related_fragments(self.fragment, relationship, options).keys.collect { |rid| rid.id } + end + + # Override this on a resource to customize how the associated records + # are fetched for a model. Particularly helpful for authorization. + def records_for(relation_name) + _model.public_send relation_name + end + + module ClassMethods + # Finds Resources using the `filters`. Pagination and sort options are used when provided + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Hash] :sort_criteria The `sort criteria` + # @option options [Hash] :include_directives The `include_directives` + # + # @return [Array] the Resource instances matching the filters, sorting and pagination rules. + def find(filters, options = {}) + context = options[:context] + + records = filter_records(records(options), filters, options) + + sort_criteria = options.fetch(:sort_criteria) { [] } + order_options = construct_order_options(sort_criteria) + records = sort_records(records, order_options, context) + + records = apply_pagination(records, options[:paginator], order_options) + + resources_for(records, context) + end + + # Counts Resources found using the `filters` + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [Integer] the count + def count(filters, options = {}) + count_records(filter_records(records(options), filters, options)) + end + + # Returns the single Resource identified by `key` + # + # @param key the primary key of the resource to find + # @option options [Hash] :context The context of the request, set in the controller + def find_by_key(key, options = {}) + context = options[:context] + records = records(options) + + records = apply_includes(records, options) + model = records.where({_primary_key => key}).first + fail JSONAPI::Exceptions::RecordNotFound.new(key) if model.nil? + self.resource_klass_for_model(model).new(model, context) + end + + # Returns an array of Resources identified by the `keys` array + # + # @param keys [Array] Array of primary keys to find resources for + # @option options [Hash] :context The context of the request, set in the controller + def find_by_keys(keys, options = {}) + context = options[:context] + records = records(options) + records = apply_includes(records, options) + models = records.where({_primary_key => keys}) + models.collect do |model| + self.resource_klass_for_model(model).new(model, context) + end + end + + # Returns an array of Resources identified by the `keys` array. The resources are not filtered as this + # will have been done in a prior step + # + # @param keys [Array] Array of primary keys to find resources for + # @option options [Hash] :context The context of the request, set in the controller + def find_to_populate_by_keys(keys, options = {}) + records = records_for_populate(options).where(_primary_key => keys) + resources_for(records, options[:context]) + end + + # Finds Resource fragments using the `filters`. Pagination and sort options are used when provided. + # Note: This is incompatible with Polymorphic resources (which are going to come from two separate tables) + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Hash] :sort_criteria The `sort criteria` + # @option options [Hash] :include_directives The `include_directives` + # @option options [Boolean] :cache Return the resources' cache field + # + # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field}] + # the ResourceInstances matching the filters, sorting, and pagination rules along with any request + # additional_field values + def find_fragments(filters, options = {}) + context = options[:context] + + sort_criteria = options.fetch(:sort_criteria) { [] } + order_options = construct_order_options(sort_criteria) + + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + filters: filters, + sort_criteria: sort_criteria) + + options[:_relation_helper_options] = { join_manager: join_manager, sort_fields: [] } + include_directives = options[:include_directives] + + records = records(options) + + records = apply_joins(records, join_manager, options) + + records = filter_records(records, filters, options) + + records = sort_records(records, order_options, context) + + records = apply_pagination(records, options[:paginator], order_options) + + resources = resources_for(records, context) + + fragments = {} + + linkage_relationships = to_one_relationships_for_linkage(include_directives.try(:[], :include_related)) + + resources.each do |resource| + rid = resource.identity + + cache = options[:cache] ? resource.cache_field_value : nil + + fragment = JSONAPI::ResourceFragment.new(rid, resource: resource, cache: cache, primary: true) + complete_linkages(fragment, linkage_relationships) + fragments[rid] ||= fragment + end + + fragments + end + + # Finds Resource Fragments related to the source resources through the specified relationship + # + # @param source_fragment [ResourceFragment>] The resource to find related ResourcesFragments for + # @param relationship_name [String | Symbol] The name of the relationship + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Boolean] :cache Return the resources' cache field + # + # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field, related: {relationship_name: [] }}}] + # the ResourceInstances matching the filters, sorting, and pagination rules along with any request + # additional_field values + def find_related_fragments(source_fragment, relationship, options) + fragments = {} + include_directives = options[:include_directives] + + resource_klass = relationship.resource_klass + + linkage_relationships = resource_klass.to_one_relationships_for_linkage(include_directives.try(:[], :include_related)) + + resources = source_fragment.resource.send(relationship.name, options) + resources = [] if resources.nil? + resources = [resources] unless resources.is_a?(Array) + + # Do not pass in source as it will setup linkage data to the source + load_resources_to_fragments(fragments, resources, nil, relationship, linkage_relationships, options) + + fragments + end + + def find_included_fragments(source_fragments, relationship, options) + fragments = {} + include_directives = options[:include_directives] + resource_klass = relationship.resource_klass + + linkage_relationships = if relationship.polymorphic? + [] + else + resource_klass.to_one_relationships_for_linkage(include_directives.try(:[], :include_related)) + end + + source_fragments.each do |source_fragment| + raise "Missing resource in fragment #{__callee__}" unless source_fragment.resource.present? + + resources = source_fragment.resource.send(relationship.name, options.except(:sort_criteria)) + resources = [] if resources.nil? + resources = [resources] unless resources.is_a?(Array) + + load_resources_to_fragments(fragments, resources, source_fragment, relationship, linkage_relationships, options) + end + + fragments + end + + def find_related_fragments_from_inverse(source, source_relationship, options, connect_source_identity) + raise "Not Implemented #{__callee__}" + end + + # Counts Resources related to the source resource through the specified relationship + # + # @param source_rid [ResourceIdentity] Source resource identifier + # @param relationship_name [String | Symbol] The name of the relationship + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [Integer] the count + + def count_related(source, relationship, options = {}) + opts = options.except(:paginator) + + related_resource_records = source.public_send("records_for_#{relationship.name}", + opts) + count_records(related_resource_records) + end + + # This resource class (ActiveRelationResource) uses an `ActiveRecord::Relation` as the starting point for + # retrieving models. From this relation filters, sorts and joins are applied as needed. + # Depending on which phase of the request processing different `records` methods will be called, giving the user + # the opportunity to override them differently for performance and security reasons. + + # begin `records`methods + + # Base for the `records` methods that follow and is not directly used for accessing model data by this class. + # Overriding this method gives a single place to affect the `ActiveRecord::Relation` used for the resource. + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records_base(_options = {}) + _model_class.all + end + + # The `ActiveRecord::Relation` used for finding user requested models. This may be overridden to enforce + # permissions checks on the request. + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records(options = {}) + records_base(options) + end + + # The `ActiveRecord::Relation` used for populating the ResourceSet. Only resources that have been previously + # identified through the `records` method will be accessed. Thus it should not be necessary to reapply permissions + # checks. However if the model needs to include other models adding `includes` is appropriate + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records_for_populate(options = {}) + records_base(options) + end + + # The `ActiveRecord::Relation` used for the finding related resources. + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records_for_source_to_related(options = {}) + records_base(options) + end + + # end `records` methods + + def load_resources_to_fragments(fragments, related_resources, source_resource, source_relationship, linkage_relationships, options) + cached = options[:cache] + primary = source_resource.nil? + + related_resources.each do |related_resource| + cache = cached ? related_resource.cache_field_value : nil + + fragment = fragments[related_resource.identity] + + if fragment.nil? + fragment = JSONAPI::ResourceFragment.new(related_resource.identity, + resource: related_resource, + cache: cache, + primary: primary) + + fragments[related_resource.identity] = fragment + complete_linkages(fragment, linkage_relationships) + end + + if source_resource + source_resource.add_related_identity(source_relationship.name, related_resource.identity) + fragment.add_related_from(source_resource.identity) + fragment.add_related_identity(source_relationship.inverse_relationship, source_resource.identity) + end + end + end + + def complete_linkages(fragment, linkage_relationships) + linkage_relationships.each do |linkage_relationship| + related_id = fragment.resource._model.attributes[linkage_relationship.foreign_key.to_s] + + related_rid = if related_id + if linkage_relationship.polymorphic? + related_type = fragment.resource._model.attributes[linkage_relationship.polymorphic_type] + JSONAPI::ResourceIdentity.new(Resource.resource_klass_for(related_type), related_id) + else + klass = linkage_relationship.resource_klass + JSONAPI::ResourceIdentity.new(klass, related_id) + end + else + nil + end + + fragment.add_related_identity(linkage_relationship.name, related_rid) + end + end + + def apply_join(records:, relationship:, resource_type:, join_type:, options:) + if relationship.polymorphic? && relationship.belongs_to? + case join_type + when :inner + records = records.joins(resource_type.to_s.singularize.to_sym) + when :left + records = records.joins_left(resource_type.to_s.singularize.to_sym) + end + else + relation_name = relationship.relation_name(options) + + # if relationship.alias_on_join + # alias_name = "#{relationship.preferred_alias}_#{relation_name}" + # case join_type + # when :inner + # records = records.joins_with_alias(relation_name, alias_name) + # when :left + # records = records.left_joins_with_alias(relation_name, alias_name) + # end + # else + case join_type + when :inner + records = records.joins(relation_name) + when :left + records = records.left_joins(relation_name) + end + end + # end + records + end + + def define_relationship_methods(relationship_name, relationship_klass, options) + foreign_key = super + + relationship = _relationship(relationship_name) + + case relationship + when JSONAPI::Relationship::ToOne + associated = define_resource_relationship_accessor(:one, relationship_name) + args = [relationship, foreign_key, associated, relationship_name] + + relationship.belongs_to? ? build_belongs_to(*args) : build_has_one(*args) + when JSONAPI::Relationship::ToMany + associated = define_resource_relationship_accessor(:many, relationship_name) + + build_to_many(relationship, foreign_key, associated, relationship_name) + end + end + + + def define_resource_relationship_accessor(type, relationship_name) + associated_records_method_name = { + one: "record_for_#{relationship_name}", + many: "records_for_#{relationship_name}" + }.fetch(type) + + define_on_resource associated_records_method_name do |options = {}| + relationship = self.class._relationships[relationship_name] + relation_name = relationship.relation_name(context: @context) + records = records_for(relation_name) + + resource_klass = relationship.resource_klass + + include_directives = options[:include_directives]&.include_directives&.dig(relationship_name) + + options = options.dup + options[:include_directives] = include_directives + + records = resource_klass.apply_includes(records, options) + + filters = options.fetch(:filters, {}) + unless filters.nil? || filters.empty? + records = resource_klass.apply_filters(records, filters, options) + end + + sort_criteria = options.fetch(:sort_criteria, {}) + order_options = relationship.resource_klass.construct_order_options(sort_criteria) + records = resource_klass.apply_sort(records, order_options, options) + + paginator = options[:paginator] + if paginator + records = resource_klass.apply_pagination(records, paginator, order_options) + end + + records + end + + associated_records_method_name + end + + def build_belongs_to(relationship, foreign_key, associated_records_method_name, relationship_name) + # Calls method matching foreign key name on model instance + define_on_resource foreign_key do + @model.method(foreign_key).call + end + + # Returns instantiated related resource object or nil + define_on_resource relationship_name do |options = {}| + relationship = self.class._relationships[relationship_name] + + if relationship.polymorphic? + associated_model = public_send(associated_records_method_name) + resource_klass = self.class.resource_klass_for_model(associated_model) if associated_model + return resource_klass.new(associated_model, @context) if resource_klass + else + resource_klass = relationship.resource_klass + if resource_klass + associated_model = public_send(associated_records_method_name) + return associated_model ? resource_klass.new(associated_model, @context) : nil + end + end + end + end + + def build_has_one(relationship, foreign_key, associated_records_method_name, relationship_name) + # Returns primary key name of related resource class + define_on_resource foreign_key do + relationship = self.class._relationships[relationship_name] + + record = public_send(associated_records_method_name) + return nil if record.nil? + record.public_send(relationship.resource_klass._primary_key) + end + + # Returns instantiated related resource object or nil + define_on_resource relationship_name do |options = {}| + relationship = self.class._relationships[relationship_name] + + if relationship.polymorphic? + associated_model = public_send(associated_records_method_name) + resource_klass = self.class.resource_klass_for_model(associated_model) if associated_model + return resource_klass.new(associated_model, @context) if resource_klass && associated_model + else + resource_klass = relationship.resource_klass + if resource_klass + associated_model = public_send(associated_records_method_name) + return associated_model ? resource_klass.new(associated_model, @context) : nil + end + end + end + end + + def build_to_many(relationship, foreign_key, associated_records_method_name, relationship_name) + # Returns array of primary keys of related resource classes + define_on_resource foreign_key do + records = public_send(associated_records_method_name) + return records.collect do |record| + record.public_send(relationship.resource_klass._primary_key) + end + end + + # Returns array of instantiated related resource objects + define_on_resource relationship_name do |options = {}| + relationship = self.class._relationships[relationship_name] + + resource_klass = relationship.resource_klass + records = public_send(associated_records_method_name, options) + + return records.collect do |record| + if relationship.polymorphic? + resource_klass = self.class.resource_for_model(record) + end + resource_klass.new(record, @context) + end + end + end + + def resolve_relationship_names_to_relations(resource_klass, model_includes, options = {}) + case model_includes + when Array + return model_includes.map do |value| + resolve_relationship_names_to_relations(resource_klass, value, options) + end + when Hash + model_includes.keys.each do |key| + relationship = resource_klass._relationships[key] + value = model_includes[key] + model_includes.delete(key) + model_includes[relationship.relation_name(options)] = resolve_relationship_names_to_relations(relationship.resource_klass, value, options) + end + return model_includes + when Symbol + relationship = resource_klass._relationships[model_includes] + return relationship.relation_name(options) + end + end + + def apply_includes(records, options = {}) + include_directives = options[:include_directives] + if include_directives + model_includes = resolve_relationship_names_to_relations(self, include_directives.model_includes, options) + records = records.includes(model_includes) + end + + records + end + + def apply_joins(records, join_manager, options) + join_manager.join(records, options) + end + + def apply_pagination(records, paginator, order_options) + records = paginator.apply(records, order_options) if paginator + records + end + + def apply_sort(records, order_options, options) + if order_options.any? + order_options.each_pair do |field, direction| + records = apply_single_sort(records, field, direction, options) + end + end + + records + end + + def apply_single_sort(records, field, direction, options) + strategy = _allowed_sort.fetch(field.to_sym, {})[:apply] + + delegated_field = attribute_to_model_field(field) + + options[:_relation_helper_options] ||= {} + options[:_relation_helper_options][:sort_fields] ||= [] + + if strategy + records = call_method_or_proc(strategy, records, direction, options) + else + join_manager = options.dig(:_relation_helper_options, :join_manager) + sort_field = join_manager ? get_aliased_field(delegated_field[:name], join_manager) : delegated_field[:name] + options[:_relation_helper_options][:sort_fields].push("#{sort_field}") + records = records.order(Arel.sql("#{sort_field} #{direction}")) + end + records + end + + def _lookup_association_chain(model_names) + associations = [] + model_names.inject do |prev, current| + association = prev.classify.constantize.reflect_on_all_associations.detect do |assoc| + assoc.name.to_s.underscore == current.underscore + end + associations << association + association.class_name + end + + associations + end + + def _build_joins(associations) + joins = [] + + associations.inject do |prev, current| + joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.id = #{prev.table_name}.#{current.foreign_key}" + current + end + joins.join("\n") + end + + def concat_table_field(table, field, quoted = false) + if table.blank? + split_table, split_field = field.to_s.split('.') + if split_table && split_field + table = split_table + field = split_field + end + end + if table.blank? + # :nocov: + if quoted + quote_column_name(field) + else + field.to_s + end + # :nocov: + else + if quoted + "#{quote_table_name(table)}.#{quote_column_name(field)}" + else + # :nocov: + "#{table.to_s}.#{field.to_s}" + # :nocov: + end + end + end + + def get_aliased_field(path_with_field, join_manager) + path = JSONAPI::Path.new(resource_klass: self, path_string: path_with_field) + + relationship_segment = path.segments[-2] + field_segment = path.segments[-1] + + if relationship_segment + join_details = join_manager.join_details[path.last_relationship] + table_alias = join_details[:alias] + else + table_alias = self._table_name + end + + concat_table_field(table_alias, field_segment.delegated_field_name) + end + + def apply_filter(records, filter, value, options = {}) + strategy = _allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] + + if strategy + records = call_method_or_proc(strategy, records, value, options) + else + join_manager = options.dig(:_relation_helper_options, :join_manager) + field = join_manager ? get_aliased_field(filter, join_manager) : filter.to_s + records = records.where(Arel.sql(field) => value) + end + + records + end + + def apply_filters(records, filters, options = {}) + # required_includes = [] + + if filters + filters.each do |filter, value| + if _relationships.include?(filter) && _allowed_filters.fetch(filter.to_sym, Hash.new)[:apply].blank? + if _relationships[filter].belongs_to? + records = apply_filter(records, _relationships[filter].foreign_key, value, options) + else + # required_includes.push(filter.to_s) + records = apply_filter(records, "#{_relationships[filter].table_name}.#{_relationships[filter].primary_key}", value, options) + end + else + records = apply_filter(records, filter, value, options) + end + end + end + + # if required_includes.any? + # records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(self, required_includes, force_eager_load: true))) + # end + + records + end + + def filter_records(records, filters, options) + records = apply_filters(records, filters, options) + apply_includes(records, options) + end + + def construct_order_options(sort_params) + sort_params ||= default_sort + + return {} unless sort_params + + sort_params.each_with_object({}) do |sort, order_hash| + field = sort[:field].to_s == 'id' ? _primary_key : sort[:field].to_s + order_hash[field] = sort[:direction] + end + end + + def sort_records(records, order_options, options = {}) + apply_sort(records, order_options, options) + end + + # Assumes ActiveRecord's counting. Override if you need a different counting method + def count_records(records) + records.count(:all) + end + + def find_count(filters, options = {}) + count_records(filter_records(records(options), filters, options)) + end + + def relationship_records(relationship:, join_type: :inner, resource_type: nil, options: {}) + records = relationship.parent_resource.records_for_source_to_related(options) + strategy = relationship.options[:apply_join] + + if strategy + records = call_method_or_proc(strategy, records, relationship, resource_type, join_type, options) + else + records = apply_join(records: records, + relationship: relationship, + resource_type: resource_type, + join_type: join_type, + options: options) + end + + records + end + + def join_relationship(records:, relationship:, resource_type: nil, join_type: :inner, options: {}) + relationship_records = relationship_records(relationship: relationship, + join_type: join_type, + resource_type: resource_type, + options: options) + records.merge(relationship_records) + end + + def warn_about_unused_methods + if Rails.env.development? + if !caching? && implements_class_method?(:records_for_populate) + warn "#{self}: The `records_for_populate` method is not used when caching is disabled." + end + end + end + + def implements_class_method?(method_name) + methods(false).include?(method_name) + end + end + end +end diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_retrieval_v10.rb similarity index 85% rename from lib/jsonapi/active_relation_resource.rb rename to lib/jsonapi/active_relation_retrieval_v10.rb index 771133b39..5f051c8ad 100644 --- a/lib/jsonapi/active_relation_resource.rb +++ b/lib/jsonapi/active_relation_retrieval_v10.rb @@ -1,14 +1,12 @@ # frozen_string_literal: true module JSONAPI - class ActiveRelationResource < BasicResource - root_resource - + module ActiveRelationRetrievalV10 def find_related_ids(relationship, options = {}) - self.class.find_related_fragments([self], relationship.name, options).keys.collect { |rid| rid.id } + self.class.find_related_fragments(self, relationship, options).keys.collect { |rid| rid.id } end - class << self + module ClassMethods # Finds Resources using the `filters`. Pagination and sort options are used when provided # # @param filters [Hash] the filters hash @@ -20,7 +18,7 @@ class << self def find(filters, options = {}) sort_criteria = options.fetch(:sort_criteria) { [] } - join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + join_manager = ActiveRelation::JoinManagerV10.new(resource_klass: self, filters: filters, sort_criteria: sort_criteria) @@ -42,7 +40,7 @@ def find(filters, options = {}) # # @return [Integer] the count def count(filters, options = {}) - join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + join_manager = ActiveRelation::JoinManagerV10.new(resource_klass: self, filters: filters) records = apply_request_settings_to_records(records: records(options), @@ -82,17 +80,15 @@ def find_to_populate_by_keys(keys, options = {}) end # Finds Resource fragments using the `filters`. Pagination and sort options are used when provided. - # Retrieving the ResourceIdentities and attributes does not instantiate a model instance. # Note: This is incompatible with Polymorphic resources (which are going to come from two separate tables) # # @param filters [Hash] the filters hash # @option options [Hash] :context The context of the request, set in the controller # @option options [Hash] :sort_criteria The `sort criteria` # @option options [Hash] :include_directives The `include_directives` - # @option options [Hash] :attributes Additional fields to be retrieved. # @option options [Boolean] :cache Return the resources' cache field # - # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field, attributes: => {name => value}}}] + # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field}] # the ResourceInstances matching the filters, sorting, and pagination rules along with any request # additional_field values def find_fragments(filters, options = {}) @@ -107,7 +103,7 @@ def find_fragments(filters, options = {}) join_manager = ActiveRelation::JoinManager.new(resource_klass: resource_klass, source_relationship: nil, - relationships: linkage_relationships, + relationships: linkage_relationships.collect(&:name), sort_criteria: sort_criteria, filters: filters) @@ -132,8 +128,8 @@ def find_fragments(filters, options = {}) linkage_fields = [] - linkage_relationships.each do |name| - linkage_relationship = resource_klass._relationship(name) + linkage_relationships.each do |linkage_relationship| + linkage_relationship_name = linkage_relationship.name if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? linkage_relationship.resource_types.each do |resource_type| @@ -141,7 +137,7 @@ def find_fragments(filters, options = {}) linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] primary_key = klass._primary_key - linkage_fields << {relationship_name: name, + linkage_fields << {relationship_name: linkage_relationship_name, resource_klass: klass, field: sql_field_with_alias(linkage_table_alias, primary_key), alias: alias_table_field(linkage_table_alias, primary_key)} @@ -153,7 +149,7 @@ def find_fragments(filters, options = {}) linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] primary_key = klass._primary_key - linkage_fields << {relationship_name: name, + linkage_fields << {relationship_name: linkage_relationship_name, resource_klass: klass, field: sql_field_with_alias(linkage_table_alias, primary_key), alias: alias_table_field(linkage_table_alias, primary_key)} @@ -162,14 +158,6 @@ def find_fragments(filters, options = {}) end end - model_fields = {} - attributes = options[:attributes] - attributes.try(:each) do |attribute| - model_field = resource_klass.attribute_to_model_field(attribute) - model_fields[attribute] = model_field - pluck_fields << sql_field_with_alias(resource_table_alias, model_field[:name]) - end - sort_fields = options.dig(:_relation_helper_options, :sort_fields) sort_fields.try(:each) do |field| pluck_fields << Arel.sql(field) @@ -196,10 +184,6 @@ def find_fragments(filters, options = {}) end attributes_offset+= 1 end - - model_fields.each_with_index do |k, idx| - fragments[rid].attributes[k[0]]= cast_to_attribute_type(row[idx + attributes_offset], k[1][:type]) - end end if JSONAPI.configuration.warn_on_performance_issues && (rows.length > fragments.length) @@ -214,29 +198,24 @@ def find_fragments(filters, options = {}) # @param source_rids [Array] The resources to find related ResourcesIdentities for # @param relationship_name [String | Symbol] The name of the relationship # @option options [Hash] :context The context of the request, set in the controller - # @option options [Hash] :attributes Additional fields to be retrieved. # @option options [Boolean] :cache Return the resources' cache field # - # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field, attributes: => {name => value}, related: {relationship_name: [] }}}] + # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field, related: {relationship_name: [] }}}] # the ResourceInstances matching the filters, sorting, and pagination rules along with any request # additional_field values - def find_related_fragments(source, relationship_name, options = {}) - relationship = _relationship(relationship_name) - - if relationship.polymorphic? # && relationship.foreign_key_on == :self - find_related_polymorphic_fragments(source, relationship, options, false) + def find_related_fragments(source_fragment, relationship, options = {}) + if relationship.polymorphic? + find_related_polymorphic_fragments([source_fragment], relationship, options, false) else - find_related_monomorphic_fragments(source, relationship, options, false) + find_related_monomorphic_fragments([source_fragment], relationship, options, false) end end - def find_included_fragments(source, relationship_name, options) - relationship = _relationship(relationship_name) - - if relationship.polymorphic? # && relationship.foreign_key_on == :self - find_related_polymorphic_fragments(source, relationship, options, true) + def find_included_fragments(source_fragments, relationship, options) + if relationship.polymorphic? + find_related_polymorphic_fragments(source_fragments, relationship, options, true) else - find_related_monomorphic_fragments(source, relationship, options, true) + find_related_monomorphic_fragments(source_fragments, relationship, options, true) end end @@ -247,14 +226,13 @@ def find_included_fragments(source, relationship_name, options) # @option options [Hash] :context The context of the request, set in the controller # # @return [Integer] the count - def count_related(source_resource, relationship_name, options = {}) - relationship = _relationship(relationship_name) + def count_related(source_resource, relationship, options = {}) related_klass = relationship.resource_klass filters = options.fetch(:filters, {}) # Joins in this case are related to the related_klass - join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + join_manager = ActiveRelation::JoinManagerV10.new(resource_klass: self, source_relationship: relationship, filters: filters) @@ -310,9 +288,7 @@ def records_for_populate(options = {}) records_base(options) end - # The `ActiveRecord::Relation` used for the finding related resources. Only resources that have been previously - # identified through the `records` method will be accessed and used as the basis to find related resources. Thus - # it should not be necessary to reapply permissions checks. + # The `ActiveRecord::Relation` used for the finding related resources. # # @option options [Hash] :context The context of the request, set in the controller # @@ -368,18 +344,7 @@ def join_relationship(records:, relationship:, resource_type: nil, join_type: :i records.merge(relationship_records) end - protected - - def to_one_relationships_for_linkage(include_related) - include_related ||= {} - relationships = [] - _relationships.each do |name, relationship| - if relationship.is_a?(JSONAPI::Relationship::ToOne) && !include_related.has_key?(name) && relationship.include_optional_linkage_data? - relationships << name - end - end - relationships - end + # protected def find_record_by_key(key, options = {}) record = apply_request_settings_to_records(records: records(options), primary_keys: key, options: options).first @@ -405,9 +370,9 @@ def find_related_monomorphic_fragments(source_fragments, relationship, options, sort_criteria << { field: field, direction: sort[:direction] } end - join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + join_manager = ActiveRelation::JoinManagerV10.new(resource_klass: self, source_relationship: relationship, - relationships: linkage_relationships, + relationships: linkage_relationships.collect(&:name), sort_criteria: sort_criteria, filters: filters) @@ -436,13 +401,13 @@ def find_related_monomorphic_fragments(source_fragments, relationship, options, linkage_fields = [] - linkage_relationships.each do |name| - linkage_relationship = resource_klass._relationship(name) + linkage_relationships.each do |linkage_relationship| + linkage_relationship_name = linkage_relationship.name if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? linkage_relationship.resource_types.each do |resource_type| klass = resource_klass_for(resource_type) - linkage_fields << {relationship_name: name, resource_klass: klass} + linkage_fields << {relationship_name: linkage_relationship_name, resource_klass: klass} linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] primary_key = klass._primary_key @@ -450,7 +415,7 @@ def find_related_monomorphic_fragments(source_fragments, relationship, options, end else klass = linkage_relationship.resource_klass - linkage_fields << {relationship_name: name, resource_klass: klass} + linkage_fields << {relationship_name: linkage_relationship_name, resource_klass: klass} linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] primary_key = klass._primary_key @@ -458,14 +423,6 @@ def find_related_monomorphic_fragments(source_fragments, relationship, options, end end - model_fields = {} - attributes = options[:attributes] - attributes.try(:each) do |attribute| - model_field = resource_klass.attribute_to_model_field(attribute) - model_fields[attribute] = model_field - pluck_fields << sql_field_with_alias(resource_table_alias, model_field[:name]) - end - sort_fields = options.dig(:_relation_helper_options, :sort_fields) sort_fields.try(:each) do |field| pluck_fields << Arel.sql(field) @@ -485,11 +442,6 @@ def find_related_monomorphic_fragments(source_fragments, relationship, options, attributes_offset+= 1 end - model_fields.each_with_index do |k, idx| - fragments[rid].add_attribute(k[0], cast_to_attribute_type(row[idx + attributes_offset], k[1][:type])) - attributes_offset+= 1 - end - source_rid = JSONAPI::ResourceIdentity.new(self, row[0]) fragments[rid].add_related_from(source_rid) @@ -505,7 +457,7 @@ def find_related_monomorphic_fragments(source_fragments, relationship, options, end if connect_source_identity - related_relationship = resource_klass._relationships[relationship.inverse_relationship] + related_relationship = resource_klass._relationship(relationship.inverse_relationship) if related_relationship fragments[rid].add_related_identity(related_relationship.name, source_rid) end @@ -524,7 +476,7 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, resource_klass = relationship.resource_klass include_directives = options.fetch(:include_directives, {}) - linkage_relationships = [] + linkage_relationship_paths = [] resource_types = relationship.resource_types @@ -532,13 +484,13 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, related_resource_klass = resource_klass_for(resource_type) relationships = related_resource_klass.to_one_relationships_for_linkage(include_directives[:include_related]) relationships.each do |r| - linkage_relationships << "##{resource_type}.#{r}" + linkage_relationship_paths << "##{resource_type}.#{r.name}" end end - join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + join_manager = ActiveRelation::JoinManagerV10.new(resource_klass: self, source_relationship: relationship, - relationships: linkage_relationships, + relationships: linkage_relationship_paths, filters: filters) paginator = options[:paginator] @@ -569,8 +521,6 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, relation_positions = {} relation_index = pluck_fields.length - attributes = options.fetch(:attributes, []) - # Add resource specific fields if resource_types.nil? || resource_types.length == 0 # :nocov: @@ -590,27 +540,9 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, relation_index+= 1 end - model_fields = {} - field_offset = relation_index - attributes.try(:each) do |attribute| - model_field = related_klass.attribute_to_model_field(attribute) - model_fields[attribute] = model_field - pluck_fields << sql_field_with_alias(table_alias, model_field[:name]) - relation_index+= 1 - end - - model_offset = relation_index - model_fields.each do |_k, v| - pluck_fields << Arel.sql("#{concat_table_field(table_alias, v[:name])}") - relation_index+= 1 - end - relation_positions[type] = {relation_klass: related_klass, cache_field: cache_field, - cache_offset: cache_offset, - model_fields: model_fields, - model_offset: model_offset, - field_offset: field_offset} + cache_offset: cache_offset} end end @@ -618,7 +550,7 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, linkage_fields = [] linkage_offset = relation_index - linkage_relationships.each do |linkage_relationship_path| + linkage_relationship_paths.each do |linkage_relationship_path| path = JSONAPI::Path.new(resource_klass: self, path_string: "#{relationship.name}#{linkage_relationship_path}", ensure_default_field: false) @@ -659,13 +591,13 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, related_fragments[rid].add_related_from(source_rid) if connect_source_identity - related_relationship = related_klass._relationships[relationship.inverse_relationship] + related_relationship = related_klass._relationship(relationship.inverse_relationship) if related_relationship related_fragments[rid].add_related_identity(related_relationship.name, source_rid) end end - relation_position = relation_positions[row[2].downcase.pluralize] + relation_position = relation_positions[row[2].underscore.pluralize] model_fields = relation_position[:model_fields] cache_field = relation_position[:cache_field] cache_offset = relation_position[:cache_offset] @@ -675,12 +607,6 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, related_fragments[rid].cache = cast_to_attribute_type(row[cache_offset], cache_field[:type]) end - if attributes.length > 0 - model_fields.each_with_index do |k, idx| - related_fragments[rid].add_attribute(k[0], cast_to_attribute_type(row[idx + field_offset], k[1][:type])) - end - end - linkage_fields.each_with_index do |linkage_field_details, idx| relationship = linkage_field_details[:relationship] related_fragments[rid].initialize_related(relationship.name) @@ -697,7 +623,7 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, end def apply_request_settings_to_records(records:, - join_manager: ActiveRelation::JoinManager.new(resource_klass: self), + join_manager: ActiveRelation::JoinManagerV10.new(resource_klass: self), resource_klass: self, filters: {}, primary_keys: nil, @@ -908,12 +834,24 @@ def apply_filter(records, filter, value, options = {}) records = call_method_or_proc(strategy, records, value, options) else join_manager = options.dig(:_relation_helper_options, :join_manager) - field = join_manager ? get_aliased_field(filter, join_manager) : filter + field = join_manager ? get_aliased_field(filter, join_manager) : filter.to_s records = records.where(Arel.sql(field) => value) end records end + + def warn_about_unused_methods + if Rails.env.development? + if !caching? && implements_class_method?(:records_for_populate) + warn "#{self}: The `records_for_populate` method is not used when caching is disabled." + end + end + end + + def implements_class_method?(method_name) + methods(false).include?(method_name) + end end end end diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 6cd5d8e1b..c5dc723de 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -41,7 +41,8 @@ class Configuration :default_resource_cache_field, :resource_cache_digest_function, :resource_cache_usage_report_function, - :default_exclude_links + :default_exclude_links, + :default_resource_retrieval_strategy def initialize #:underscored_key, :camelized_key, :dasherized_key, or custom @@ -160,6 +161,21 @@ def initialize # and relationships. Accepts either `:default`, `:none`, or array containing the # specific default links to exclude, which may be `:self` and `:related`. self.default_exclude_links = :none + + # Global configuration for resource retrieval strategy used by the Resource class. + # Selecting a default_resource_retrieval_strategy will affect all resources that derive from + # Resource. The default value is 'JSONAPI::ActiveRelationRetrieval'. + # + # To use multiple retrieval strategies in an app set this to :none and set a custom retrieval strategy + # per resource (or base resource) using the class method `load_resource_retrieval_strategy`. + # + # Available strategies: + # 'JSONAPI::ActiveRelationRetrieval' + # 'JSONAPI::ActiveRelationRetrievalV09' + # 'JSONAPI::ActiveRelationRetrievalV10' + # :none + # :self + self.default_resource_retrieval_strategy = 'JSONAPI::ActiveRelationRetrieval' end def cache_formatters=(bool) @@ -246,16 +262,6 @@ def allow_include=(allow_include) @default_allow_include_to_many = allow_include end - def whitelist_all_exceptions=(allow_all_exceptions) - ActiveSupport::Deprecation.warn('`whitelist_all_exceptions` has been replaced by `allow_all_exceptions`') - @allow_all_exceptions = allow_all_exceptions - end - - def exception_class_whitelist=(exception_class_allowlist) - ActiveSupport::Deprecation.warn('`exception_class_whitelist` has been replaced by `exception_class_allowlist`') - @exception_class_allowlist = exception_class_allowlist - end - attr_writer :allow_sort, :allow_filter, :default_allow_include_to_one, :default_allow_include_to_many attr_writer :default_paginator @@ -311,6 +317,8 @@ def exception_class_whitelist=(exception_class_allowlist) attr_writer :resource_cache_usage_report_function attr_writer :default_exclude_links + + attr_writer :default_resource_retrieval_strategy end class << self diff --git a/lib/jsonapi/include_directives.rb b/lib/jsonapi/include_directives.rb index 2ad300133..3914bd85b 100644 --- a/lib/jsonapi/include_directives.rb +++ b/lib/jsonapi/include_directives.rb @@ -6,46 +6,102 @@ class IncludeDirectives # For example ['posts.comments.tags'] # will transform into => # { - # posts: { - # include_related: { - # comments:{ - # include_related: { - # tags: { - # include_related: {} - # } + # include_related: { + # posts: { + # include: true, + # include_related: { + # comments: { + # include: true, + # include_related: { + # tags: { + # include: true, + # include_related: {}, + # include_in_join: true + # } + # }, + # include_in_join: true # } - # } + # }, + # include_in_join: true # } # } # } - def initialize(resource_klass, includes_array) + def initialize(resource_klass, includes_array, force_eager_load: false) @resource_klass = resource_klass + @force_eager_load = force_eager_load @include_directives_hash = { include_related: {} } includes_array.each do |include| parse_include(include) end end + def include_directives + @include_directives_hash + end + def [](name) @include_directives_hash[name] end - private + def model_includes + get_includes(@include_directives_hash) + end - def parse_include(include) - path = JSONAPI::Path.new(resource_klass: @resource_klass, - path_string: include, - ensure_default_field: false, - parse_fields: false) + private + def get_related(current_path) current = @include_directives_hash + current_resource_klass = @resource_klass + current_path.split('.').each do |fragment| + fragment = fragment.to_sym + + if current_resource_klass + current_relationship = current_resource_klass._relationship(fragment) + current_resource_klass = current_relationship.try(:resource_klass) + else + raise JSONAPI::Exceptions::InvalidInclude.new(current_resource_klass, current_path) + end + + include_in_join = @force_eager_load || !current_relationship || current_relationship.eager_load_on_include - path.segments.each do |segment| - relationship_name = segment.relationship.name.to_sym + current[:include_related][fragment] ||= { include: false, include_related: {}, include_in_join: include_in_join } + current = current[:include_related][fragment] + end + current + end + + def get_includes(directive, only_joined_includes = true) + ir = directive[:include_related] + ir = ir.select { |_k,v| v[:include_in_join] } if only_joined_includes + + ir.map do |name, sub_directive| + sub = get_includes(sub_directive, only_joined_includes) + sub.any? ? { name => sub } : name + end + end + + def parse_include(include) + parts = include.split('.') + local_path = '' + + parts.each do |name| + local_path += local_path.length > 0 ? ".#{name}" : name + related = get_related(local_path) + related[:include] = true + end + end - current[:include_related][relationship_name] ||= { include_related: {} } - current = current[:include_related][relationship_name] + def delve_paths(obj) + case obj + when Array + obj.map{|elem| delve_paths(elem)}.flatten(1) + when Hash + obj.map{|k,v| [[k]] + delve_paths(v).map{|path| [k] + path } }.flatten(1) + when Symbol, String + [[obj]] + else + raise "delve_paths cannot descend into #{obj.class.name}" end rescue JSONAPI::Exceptions::InvalidRelationship => _e diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 814642a45..6d46f000b 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -108,6 +108,7 @@ def show def show_relationship parent_key = params[:parent_key] relationship_type = params[:relationship_type].to_sym + relationship = resource_klass._relationship(relationship_type) paginator = params[:paginator] sort_criteria = params[:sort_criteria] include_directives = params[:include_directives] @@ -125,14 +126,14 @@ def show_relationship resource_tree = find_related_resource_tree( parent_resource, - relationship_type, + relationship, options, nil ) JSONAPI::RelationshipOperationResult.new(:ok, parent_resource, - resource_klass._relationship(relationship_type), + relationship, resource_tree.fragments.keys, result_options) end @@ -200,9 +201,11 @@ def show_related_resources (paginator && paginator.class.requires_record_count) || (JSONAPI.configuration.top_level_meta_include_page_count)) + relationship = source_resource.class._relationship(relationship_type) + opts[:record_count] = source_resource.class.count_related( source_resource, - relationship_type, + relationship, options) end @@ -384,11 +387,13 @@ def find_resource_tree(options, include_related) PrimaryResourceTree.new(fragments: fragments, include_related: include_related, options: options) end - def find_related_resource_tree(parent_resource, relationship_name, options, include_related) + def find_related_resource_tree(parent_resource, relationship, options, include_related) options = options.except(:include_directives) options[:cache] = resource_klass.caching? - fragments = resource_klass.find_included_fragments([parent_resource], relationship_name, options) + parent_resource_fragment = parent_resource.fragment(primary: true) + + fragments = resource_klass.find_related_fragments(parent_resource_fragment, relationship, options) PrimaryResourceTree.new(fragments: fragments, include_related: include_related, options: options) end @@ -398,7 +403,7 @@ def find_resource_tree_from_relationship(resource, relationship_name, options, i options = options.except(:include_directives) options[:cache] = relationship.resource_klass.caching? - fragments = resource.class.find_related_fragments([resource], relationship_name, options) + fragments = resource.class.find_related_fragments(resource.fragment, relationship, options) PrimaryResourceTree.new(fragments: fragments, include_related: include_related, options: options) end diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 8824fc65d..2308b0a34 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -3,9 +3,9 @@ module JSONAPI class Relationship attr_reader :acts_as_set, :foreign_key, :options, :name, - :class_name, :polymorphic, :always_include_optional_linkage_data, + :class_name, :polymorphic, :always_include_optional_linkage_data, :exclude_linkage_data, :parent_resource, :eager_load_on_include, :custom_methods, - :inverse_relationship, :allow_include + :inverse_relationship, :allow_include, :hidden attr_writer :allow_include @@ -17,7 +17,7 @@ def initialize(name, options = {}) @acts_as_set = options.fetch(:acts_as_set, false) == true @foreign_key = options[:foreign_key] ? options[:foreign_key].to_sym : nil @parent_resource = options[:parent_resource] - @relation_name = options.fetch(:relation_name, @name) + @relation_name = options[:relation_name] @polymorphic = options.fetch(:polymorphic, false) == true @polymorphic_types = options[:polymorphic_types] if options[:polymorphic_relations] @@ -25,11 +25,15 @@ def initialize(name, options = {}) @polymorphic_types ||= options[:polymorphic_relations] end + @hidden = options.fetch(:hidden, false) == true + + @exclude_linkage_data = options[:exclude_linkage_data] @always_include_optional_linkage_data = options.fetch(:always_include_optional_linkage_data, false) == true @eager_load_on_include = options.fetch(:eager_load_on_include, true) == true @allow_include = options[:allow_include] @class_name = nil - @inverse_relationship = nil + + @inverse_relationship = options[:inverse_relationship]&.to_sym @_routed = false @_warned_missing_route = false @@ -59,13 +63,27 @@ def table_name # :nocov: end + def inverse_relationship + unless @inverse_relationship + @inverse_relationship ||= if resource_klass._relationship(@parent_resource._type.to_s.singularize).present? + @parent_resource._type.to_s.singularize.to_sym + elsif resource_klass._relationship(@parent_resource._type).present? + @parent_resource._type.to_sym + else + nil + end + end + + @inverse_relationship + end + def self.polymorphic_types(name) @poly_hash ||= {}.tap do |hash| ObjectSpace.each_object do |klass| next unless Module === klass if ActiveRecord::Base > klass - klass.reflect_on_all_associations(:has_many).select{|r| r.options[:as] }.each do |reflection| - (hash[reflection.options[:as]] ||= []) << klass.name.downcase + klass.reflect_on_all_associations(:has_many).select { |r| r.options[:as] }.each do |reflection| + (hash[reflection.options[:as]] ||= []) << klass.name.underscore end end end @@ -75,7 +93,7 @@ def self.polymorphic_types(name) def resource_types if polymorphic? && belongs_to? - @polymorphic_types ||= self.class.polymorphic_types(@relation_name).collect {|t| t.pluralize} + @polymorphic_types ||= self.class.polymorphic_types(_relation_name).collect { |t| t.pluralize } else [resource_klass._type.to_s.pluralize] end @@ -86,15 +104,15 @@ def type end def relation_name(options) - case @relation_name - when Symbol - # :nocov: - @relation_name - # :nocov: - when String - @relation_name.to_sym - when Proc - @relation_name.call(options) + case _relation_name + when Symbol + # :nocov: + _relation_name + # :nocov: + when String + _relation_name.to_sym + when Proc + _relation_name.call(options) end end @@ -110,14 +128,14 @@ def readonly? def exclude_links(exclude) case exclude - when :default, "default" - @_exclude_links = [:self, :related] - when :none, "none" - @_exclude_links = [] - when Array - @_exclude_links = exclude.collect {|link| link.to_sym} - else - fail "Invalid exclude_links" + when :default, "default" + @_exclude_links = [:self, :related] + when :none, "none" + @_exclude_links = [] + when Array + @_exclude_links = exclude.collect { |link| link.to_sym } + else + fail "Invalid exclude_links" end end @@ -129,6 +147,10 @@ def exclude_link?(link) _exclude_links.include?(link.to_sym) end + def _relation_name + @relation_name || @name + end + class ToOne < Relationship attr_reader :foreign_key_on @@ -137,9 +159,16 @@ def initialize(name, options = {}) @class_name = options.fetch(:class_name, name.to_s.camelize) @foreign_key ||= "#{name}_id".to_sym @foreign_key_on = options.fetch(:foreign_key_on, :self) - if parent_resource - @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type) + # if parent_resource + # @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type) + # end + + if options.fetch(:create_implicit_polymorphic_type_relationships, true) == true && polymorphic? + # Setup the implicit relationships for the polymorphic types and exclude linkage data + setup_implicit_relationships_for_polymorphic_types end + + @polymorphic_type_relationship_for = options[:polymorphic_type_relationship_for] end def to_s @@ -154,11 +183,30 @@ def belongs_to? # :nocov: end + def hidden? + @hidden || @polymorphic_type_relationship_for.present? + end + def polymorphic_type "#{name}_type" if polymorphic? end + def setup_implicit_relationships_for_polymorphic_types(exclude_linkage_data: true) + types = self.class.polymorphic_types(_relation_name) + unless types.present? + warn "No polymorphic types found for #{parent_resource.name} #{_relation_name}" + return + end + + types.each do |type| + parent_resource.has_one(type.to_s.underscore.singularize, + exclude_linkage_data: exclude_linkage_data, + polymorphic_type_relationship_for: name) + end + end + def include_optional_linkage_data? + return false if @exclude_linkage_data @always_include_optional_linkage_data || JSONAPI::configuration.always_include_to_one_linkage_data end @@ -169,10 +217,10 @@ def allow_include?(context = nil) @allow_include end - if !!strategy == strategy #check for boolean + if !!strategy == strategy # check for boolean return strategy elsif strategy.is_a?(Symbol) || strategy.is_a?(String) - parent_resource.send(strategy, context) + parent_resource_klass.send(strategy, context) else strategy.call(context) end @@ -187,17 +235,21 @@ def initialize(name, options = {}) @class_name = options.fetch(:class_name, name.to_s.camelize.singularize) @foreign_key ||= "#{name.to_s.singularize}_ids".to_sym @reflect = options.fetch(:reflect, true) == true - if parent_resource - @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type.to_s.singularize.to_sym) - end + # if parent_resource + # @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type.to_s.singularize.to_sym) + # end end def to_s # :nocov: useful for debugging - "#{parent_resource}.#{name}(ToMany)" + "#{parent_resource_klass}.#{name}(ToMany)" # :nocov: end + def hidden? + @hidden + end + def include_optional_linkage_data? # :nocov: @always_include_optional_linkage_data || JSONAPI::configuration.always_include_to_many_linkage_data @@ -211,10 +263,10 @@ def allow_include?(context = nil) @allow_include end - if !!strategy == strategy #check for boolean + if !!strategy == strategy # check for boolean return strategy elsif strategy.is_a?(Symbol) || strategy.is_a?(String) - parent_resource.send(strategy, context) + parent_resource_klass.send(strategy, context) else strategy.call(context) end diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 4d34dd290..7551178a8 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -1,7 +1,10 @@ # frozen_string_literal: true module JSONAPI - class Resource < ActiveRelationResource + class Resource + include ResourceCommon root_resource + abstract + immutable end -end \ No newline at end of file +end diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/resource_common.rb similarity index 81% rename from lib/jsonapi/basic_resource.rb rename to lib/jsonapi/resource_common.rb index 2eeba5c5d..ca0d67b6f 100644 --- a/lib/jsonapi/basic_resource.rb +++ b/lib/jsonapi/resource_common.rb @@ -1,30 +1,27 @@ # frozen_string_literal: true -require 'jsonapi/callbacks' -require 'jsonapi/configuration' - module JSONAPI - class BasicResource - include Callbacks - - @abstract = true - @immutable = true - @root = true - - attr_reader :context - - define_jsonapi_resources_callbacks :create, - :update, - :remove, - :save, - :create_to_many_link, - :replace_to_many_links, - :create_to_one_link, - :replace_to_one_link, - :replace_polymorphic_to_one_link, - :remove_to_many_link, - :remove_to_one_link, - :replace_fields + module ResourceCommon + + def self.included(base) + base.extend ClassMethods + + base.include Callbacks + base.define_jsonapi_resources_callbacks :create, + :update, + :remove, + :save, + :create_to_many_link, + :replace_to_many_links, + :create_to_one_link, + :replace_to_one_link, + :replace_polymorphic_to_one_link, + :remove_to_many_link, + :remove_to_one_link, + :replace_fields + + base.attr_reader :context + end def initialize(model, context) @model = model @@ -46,6 +43,10 @@ def identity JSONAPI::ResourceIdentity.new(self.class, id) end + def fragment(cache: nil, primary: false) + @fragment ||= JSONAPI::ResourceFragment.new(identity, resource: self, cache: cache, primary: primary) + end + def cache_field_value _model.public_send(self.class._cache_field) end @@ -238,7 +239,7 @@ def reflect_relationship?(relationship, options) return false if !relationship.reflect || (!JSONAPI.configuration.use_relationship_reflection || options[:reflected_source]) - inverse_relationship = relationship.resource_klass._relationships[relationship.inverse_relationship] + inverse_relationship = relationship.resource_klass._relationship(relationship.inverse_relationship) if inverse_relationship.nil? warn "Inverse relationship could not be found for #{self.class.name}.#{relationship.name}. Relationship reflection disabled." return false @@ -247,7 +248,7 @@ def reflect_relationship?(relationship, options) end def _create_to_many_links(relationship_type, relationship_key_values, options) - relationship = self.class._relationships[relationship_type] + relationship = self.class._relationship(relationship_type) relation_name = relationship.relation_name(context: @context) if options[:reflected_source] @@ -269,7 +270,7 @@ def _create_to_many_links(relationship_type, relationship_key_values, options) related_resources.each do |related_resource| if reflect - if related_resource.class._relationships[relationship.inverse_relationship].is_a?(JSONAPI::Relationship::ToMany) + if related_resource.class._relationship(relationship.inverse_relationship).is_a?(JSONAPI::Relationship::ToMany) related_resource.create_to_many_links(relationship.inverse_relationship, [id], reflected_source: self) else related_resource.replace_to_one_link(relationship.inverse_relationship, id, reflected_source: self) @@ -308,8 +309,8 @@ def _replace_to_many_links(relationship_type, relationship_key_values, options) ids = relationship_key_value[:ids] related_records = relationship_resource_klass - .records(options) - .where({relationship_resource_klass._primary_key => ids}) + .records(options) + .where({relationship_resource_klass._primary_key => ids}) missed_ids = ids - related_records.pluck(relationship_resource_klass._primary_key) @@ -331,7 +332,7 @@ def _replace_to_many_links(relationship_type, relationship_key_values, options) end def _replace_to_one_link(relationship_type, relationship_key_value, _options) - relationship = self.class._relationships[relationship_type] + relationship = self.class._relationship(relationship_type) send("#{relationship.foreign_key}=", relationship_key_value) @save_needed = true @@ -340,7 +341,7 @@ def _replace_to_one_link(relationship_type, relationship_key_value, _options) end def _replace_polymorphic_to_one_link(relationship_type, key_value, key_type, _options) - relationship = self.class._relationships[relationship_type.to_sym] + relationship = self.class._relationship(relationship_type.to_sym) send("#{relationship.foreign_key}=", {type: key_type, id: key_value}) @save_needed = true @@ -349,7 +350,7 @@ def _replace_polymorphic_to_one_link(relationship_type, key_value, key_type, _op end def _remove_to_many_link(relationship_type, key, options) - relationship = self.class._relationships[relationship_type] + relationship = self.class._relationship(relationship_type) reflect = reflect_relationship?(relationship, options) @@ -360,7 +361,7 @@ def _remove_to_many_link(relationship_type, key, options) if related_resource.nil? fail JSONAPI::Exceptions::RecordNotFound.new(key) else - if related_resource.class._relationships[relationship.inverse_relationship].is_a?(JSONAPI::Relationship::ToMany) + if related_resource.class._relationship(relationship.inverse_relationship).is_a?(JSONAPI::Relationship::ToMany) related_resource.remove_to_many_link(relationship.inverse_relationship, id, reflected_source: self) else related_resource.remove_to_one_link(relationship.inverse_relationship, reflected_source: self) @@ -381,7 +382,7 @@ def _remove_to_many_link(relationship_type, key, options) end def _remove_to_one_link(relationship_type, _options) - relationship = self.class._relationships[relationship_type] + relationship = self.class._relationship(relationship_type) send("#{relationship.foreign_key}=", nil) @save_needed = true @@ -425,9 +426,52 @@ def find_related_ids(relationship, options = {}) send(relationship.foreign_key) end - class << self + module ClassMethods + def resource_retrieval_strategy(module_name = JSONAPI.configuration.default_resource_retrieval_strategy) + if @_resource_retrieval_strategy_loaded + warn "Resource retrieval strategy #{@_resource_retrieval_strategy_loaded} already loaded for #{self.name}" + return + end + + module_name = module_name.to_s + + return if module_name.blank? || module_name == 'self' || module_name == 'none' + + class_eval do + resource_retrieval_module = module_name.safe_constantize + raise "Unable to find resource_retrieval_strategy #{module_name}" unless resource_retrieval_module + + include resource_retrieval_module + extend "#{module_name}::ClassMethods".safe_constantize + @_resource_retrieval_strategy_loaded = module_name + end + end + + def warn_about_missing_retrieval_methods + resource_retrieval_methods = %i[find count find_by_key find_by_keys find_to_populate_by_keys find_fragments + find_related_fragments find_included_fragments count_related] + + resource_retrieval_methods.each do |method_name| + warn "#{self.name} has not defined standard method #{method_name}" unless self.respond_to?(method_name) + end + end + def inherited(subclass) super + + # Defer loading the resource retrieval strategy module until the class has been fully read to allow setting + # a custom resource_retrieval_strategy in the class definition + trace_point = TracePoint.new(:end) do |tp| + if subclass == tp.self + unless subclass._abstract + subclass.warn_about_missing_retrieval_methods + subclass.warn_about_unused_methods if subclass.methods.include?(:warn_about_unused_methods) + end + tp.disable + end + end + trace_point.enable + subclass.abstract(false) subclass.immutable(false) subclass.caching(_caching) @@ -465,6 +509,9 @@ def inherited(subclass) subclass._clear_cached_attribute_options subclass._clear_fields_cache + + subclass._resource_retrieval_strategy_loaded = @_resource_retrieval_strategy_loaded + subclass.resource_retrieval_strategy unless subclass._resource_retrieval_strategy_loaded end def rebuild_relationships(relationships) @@ -476,7 +523,7 @@ def rebuild_relationships(relationships) original_relationships.each_value do |relationship| options = relationship.options.dup options[:parent_resource] = self - options[:inverse_relationship] = relationship.inverse_relationship + options[:inverse_relationship] = relationship.options[:inverse_relationship] _add_relationship(relationship.class, relationship.name, options) end end @@ -511,7 +558,8 @@ def resource_type_for(model) end end - attr_accessor :_attributes, :_relationships, :_type, :_model_hints, :_routed, :_warned_missing_route + attr_accessor :_attributes, :_relationships, :_type, :_model_hints, :_routed, :_warned_missing_route, + :_resource_retrieval_strategy_loaded attr_writer :_allowed_filters, :_paginator, :_allowed_sort def create(context) @@ -574,7 +622,7 @@ def attribute_to_model_field(attribute) # Note: this will allow the returning of model attributes without a corresponding # resource attribute, for example a belongs_to id such as `author_id` or bypassing # the delegate. - attr = @_attributes[attribute] + attr = @_attributes[attribute.to_sym] attr && attr[:delegate] ? attr[:delegate].to_sym : attribute end @@ -592,14 +640,14 @@ def default_attribute_options def relationship(*attrs) options = attrs.extract_options! klass = case options[:to] - when :one - Relationship::ToOne - when :many - Relationship::ToMany - else - #:nocov:# - fail ArgumentError.new('to: must be either :one or :many') - #:nocov:# + when :one + Relationship::ToOne + when :many + Relationship::ToMany + else + #:nocov:# + fail ArgumentError.new('to: must be either :one or :many') + #:nocov:# end _add_relationship(klass, *attrs, options.except(:to)) end @@ -610,10 +658,10 @@ def has_one(*attrs) def belongs_to(*attrs) ActiveSupport::Deprecation.warn "In #{name} you exposed a `has_one` relationship "\ - " using the `belongs_to` class method. We think `has_one`" \ - " is more appropriate. If you know what you're doing," \ - " and don't want to see this warning again, override the" \ - " `belongs_to` class method on your resource." + " using the `belongs_to` class method. We think `has_one`" \ + " is more appropriate. If you know what you're doing," \ + " and don't want to see this warning again, override the" \ + " `belongs_to` class method on your resource." _add_relationship(Relationship::ToOne, *attrs) end @@ -637,7 +685,7 @@ def model_name(model, options = {}) end def model_hint(model: _model_name, resource: _type) - resource_type = ((resource.is_a?(Class)) && (resource < JSONAPI::BasicResource)) ? resource._type : resource.to_s + resource_type = ((resource.is_a?(Class)) && resource.include?(JSONAPI::ResourceCommon)) ? resource._type : resource.to_s _model_hints[model.to_s.gsub('::', '/').underscore] = resource_type.to_s end @@ -700,7 +748,22 @@ def sortable_field?(key, context = nil) end def fields - @_fields_cache ||= _relationships.keys | _attributes.keys + @_fields_cache ||= _relationships.select { |k,v| !v.hidden? }.keys | _attributes.keys + end + + def to_one_relationships_including_optional_linkage_data + # ToDo: can we only calculate this once? + @to_one_relationships_including_optional_linkage_data = + _relationships.select do |_name, relationship| + relationship.is_a?(JSONAPI::Relationship::ToOne) && relationship.include_optional_linkage_data? + end + end + + def to_one_relationships_for_linkage(include_related) + # exclude the relationships that are already included in the include_related param + include_related_names = include_related.present? ? include_related.keys : [] + relationship_names = to_one_relationships_including_optional_linkage_data.keys - include_related_names + _relationships.fetch_values(*relationship_names) end def resources_for(records, context) @@ -714,6 +777,10 @@ def resource_for(model_record, context) resource_klass.new(model_record, context) end + def resource_for_model(model, context) + resource_for(resource_type_for(model), context) + end + def verify_filters(filters, context = nil) verified_filters = {} filters.each do |filter, raw_value| @@ -774,12 +841,12 @@ def singleton_key(context) if @_singleton_options && @_singleton_options[:singleton_key] strategy = @_singleton_options[:singleton_key] case strategy - when Proc - key = strategy.call(context) - when Symbol, String - key = send(strategy, context) - else - raise "singleton_key must be a proc or function name" + when Proc + key = strategy.call(context) + when Symbol, String + key = send(strategy, context) + else + raise "singleton_key must be a proc or function name" end end key @@ -854,13 +921,12 @@ def _updatable_relationships def _relationship(type) return nil unless type - type = type.to_sym - @_relationships[type] + @_relationships[type.to_sym] end def _model_name if _abstract - '' + '' else return @_model_name.to_s if defined?(@_model_name) class_name = self.name @@ -874,7 +940,7 @@ def _polymorphic_name if !_polymorphic '' else - @_polymorphic_name ||= _model_name.to_s.downcase + @_polymorphic_name ||= _model_name.to_s.underscore end end @@ -883,7 +949,7 @@ def _primary_key end def _default_primary_key - @_default_primary_key ||=_model_class.respond_to?(:primary_key) ? _model_class.primary_key : :id + @_default_primary_key ||= _model_class.respond_to?(:primary_key) ? _model_class.primary_key : :id end def _cache_field @@ -928,7 +994,7 @@ def _polymorphic_types next unless Module === klass if klass < ActiveRecord::Base klass.reflect_on_all_associations(:has_many).select{|r| r.options[:as] }.each do |reflection| - (hash[reflection.options[:as]] ||= []) << klass.name.downcase + (hash[reflection.options[:as]] ||= []) << klass.name.underscore end end end @@ -974,14 +1040,14 @@ def mutable? def parse_exclude_links(exclude) case exclude - when :default, "default" - [:self] - when :none, "none" - [] - when Array - exclude.collect {|link| link.to_sym} - else - fail "Invalid exclude_links" + when :default, "default" + [:self] + when :none, "none" + [] + when Array + exclude.collect {|link| link.to_sym} + else + fail "Invalid exclude_links" end end @@ -1017,6 +1083,22 @@ def attribute_caching_context(_context) nil end + def _included_strategy + @_included_strategy || JSONAPI.configuration.default_included_strategy + end + + def included_strategy(included_strategy) + @_included_strategy = included_strategy + end + + def _related_strategy + @_related_strategy || JSONAPI.configuration.default_related_strategy + end + + def related_strategy(related_strategy) + @_related_strategy = related_strategy + end + # Generate a hashcode from the value to be used as part of the cache lookup def hash_cache_field(value) value.hash @@ -1055,7 +1137,7 @@ def module_path end def default_sort - [{field: 'id', direction: :asc}] + [{field: _primary_key, direction: :asc}] end def construct_order_options(sort_params) @@ -1084,11 +1166,23 @@ def _add_relationship(klass, *attrs) end end + def _setup_relationship(klass, *attrs) + _clear_fields_cache + + options = attrs.extract_options! + options[:parent_resource] = self + + relationship_name = attrs[0].to_sym + check_duplicate_relationship_name(relationship_name) + + define_relationship_methods(relationship_name.to_sym, klass, options) + end + # ResourceBuilder methods def define_relationship_methods(relationship_name, relationship_klass, options) relationship = register_relationship( - relationship_name, - relationship_klass.new(relationship_name, options) + relationship_name, + relationship_klass.new(relationship_name, options) ) define_foreign_key_setter(relationship) @@ -1105,10 +1199,11 @@ def define_foreign_key_setter(relationship) _model.method("#{relationship.foreign_key}=").call(value) end end + relationship.foreign_key end def define_on_resource(method_name, &block) - return if method_defined?(method_name) + return method_name if method_defined?(method_name) define_method(method_name, block) end diff --git a/lib/jsonapi/resource_fragment.rb b/lib/jsonapi/resource_fragment.rb index c42cf573d..fb9237142 100644 --- a/lib/jsonapi/resource_fragment.rb +++ b/lib/jsonapi/resource_fragment.rb @@ -10,11 +10,9 @@ module JSONAPI # related_from - a set of related resource identities that loaded the fragment # resource - a resource instance # - # Todo: optionally use these for faster responses by bypassing model instantiation) - # attributes - resource attributes class ResourceFragment - attr_reader :identity, :attributes, :related_from, :related, :resource + attr_reader :identity, :related_from, :related, :resource attr_accessor :primary, :cache @@ -26,9 +24,8 @@ def initialize(identity, resource: nil, cache: nil, primary: false) @resource = resource @primary = primary - @attributes = {} @related = {} - @related_from = Set.new + @related_from = SortedSet.new end def initialize_related(relationship_name) @@ -48,9 +45,5 @@ def merge_related_identities(relationship_name, identities) def add_related_from(identity) @related_from << identity end - - def add_attribute(name, value) - @attributes[name] = value - end end -end \ No newline at end of file +end diff --git a/lib/jsonapi/resource_identity.rb b/lib/jsonapi/resource_identity.rb index baea3fcf8..74fae1aa9 100644 --- a/lib/jsonapi/resource_identity.rb +++ b/lib/jsonapi/resource_identity.rb @@ -34,6 +34,10 @@ def hash [@resource_klass, @id].hash end + def <=>(other_identity) + self.id <=> other_identity.id + end + # Creates a string representation of the identifier. def to_s # :nocov: diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index 731404f1e..1e7d51029 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -307,7 +307,7 @@ def cached_relationships_hash(source, fetchable_fields, relationship_data) if field_set.include?(name) relationship_name = unformat_key(name).to_sym - relationship_klass = source.resource_klass._relationships[relationship_name] + relationship_klass = source.resource_klass._relationship(relationship_name) if relationship_klass.is_a?(JSONAPI::Relationship::ToOne) # include_linkage = @always_include_to_one_linkage_data | relationship_klass.always_include_linkage_data diff --git a/lib/jsonapi/resource_set.rb b/lib/jsonapi/resource_set.rb index 01fcdb77e..894d85577 100644 --- a/lib/jsonapi/resource_set.rb +++ b/lib/jsonapi/resource_set.rb @@ -11,7 +11,7 @@ def initialize(source, include_related = nil, options = nil) @populated = false tree = if source.is_a?(JSONAPI::ResourceTree) source - elsif source.class < JSONAPI::BasicResource + elsif source.class.include?(JSONAPI::ResourceCommon) JSONAPI::PrimaryResourceTree.new(resource: source, include_related: include_related, options: options) elsif source.is_a?(Array) JSONAPI::PrimaryResourceTree.new(resources: source, include_related: include_related, options: options) @@ -180,7 +180,7 @@ def flatten_resource_tree(resource_tree, flattened_tree = {}) flattened_tree[resource_klass][id][:resource] ||= fragment.resource if fragment.resource fragment.related.try(:each_pair) do |relationship_name, related_rids| - flattened_tree[resource_klass][id][:relationships][relationship_name] ||= Set.new + flattened_tree[resource_klass][id][:relationships][relationship_name] ||= SortedSet.new flattened_tree[resource_klass][id][:relationships][relationship_name].merge(related_rids) end end diff --git a/lib/jsonapi/resource_tree.rb b/lib/jsonapi/resource_tree.rb index a7a9a0b63..5cbd830ef 100644 --- a/lib/jsonapi/resource_tree.rb +++ b/lib/jsonapi/resource_tree.rb @@ -83,7 +83,7 @@ def load_included(resource_klass, source_resource_tree, include_related, options find_related_resource_options[:cache] = resource_klass.caching? related_fragments = resource_klass.find_included_fragments(source_resource_tree.fragments.values, - relationship_name, + relationship, find_related_resource_options) related_resource_tree = source_resource_tree.get_related_resource_tree(relationship) @@ -96,51 +96,6 @@ def load_included(resource_klass, source_resource_tree, include_related, options options) end end - - def add_resources_to_tree(resource_klass, - tree, - resources, - include_related, - source_rid: nil, - source_relationship_name: nil, - connect_source_identity: true) - fragments = {} - - resources.each do |resource| - next unless resource - - # fragments[resource.identity] ||= ResourceFragment.new(resource.identity, resource: resource) - # resource_fragment = fragments[resource.identity] - # ToDo: revert when not needed for testing - resource_fragment = if fragments[resource.identity] - fragments[resource.identity] - else - fragments[resource.identity] = ResourceFragment.new(resource.identity, resource: resource) - fragments[resource.identity] - end - - if resource.class.caching? - resource_fragment.cache = resource.cache_field_value - end - - linkage_relationships = resource_klass.to_one_relationships_for_linkage(resource.class, include_related) - linkage_relationships.each do |relationship_name| - related_resource = resource.send(relationship_name) - resource_fragment.add_related_identity(relationship_name, related_resource&.identity) - end - - if source_rid && connect_source_identity - resource_fragment.add_related_from(source_rid) - source_klass = source_rid.resource_klass - related_relationship_name = source_klass._relationships[source_relationship_name].inverse_relationship - if related_relationship_name - resource_fragment.add_related_identity(related_relationship_name, source_rid) - end - end - end - - tree.add_resource_fragments(fragments, include_related) - end end class PrimaryResourceTree < ResourceTree @@ -182,7 +137,7 @@ def complete_includes!(include_related, options) resource_klasses = Set.new @fragments.each_key { |identity| resource_klasses << identity.resource_klass } - resource_klasses.each { |resource_klass| load_included(resource_klass, self, include_related, options)} + resource_klasses.each { |resource_klass| load_included(resource_klass, self, include_related, options) } self end @@ -233,4 +188,4 @@ def add_resource_fragment(fragment, include_related) end end end -end \ No newline at end of file +end diff --git a/lib/jsonapi/response_document.rb b/lib/jsonapi/response_document.rb index 3558e5e0a..9f43d5eb5 100644 --- a/lib/jsonapi/response_document.rb +++ b/lib/jsonapi/response_document.rb @@ -119,7 +119,7 @@ def update_links(serializer, result) result.pagination_params.each_pair do |link_name, params| if result.is_a?(JSONAPI::RelatedResourcesSetOperationResult) - relationship = result.source_resource.class._relationships[result._type.to_sym] + relationship = result.source_resource.class._relationship(result._type) unless relationship.exclude_link?(link_name) link = serializer.link_builder.relationships_related_link(result.source_resource, relationship, query_params(params)) end diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index b0b940138..5098d73d0 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -224,7 +224,7 @@ def jsonapi_related_resource(*relationship) options = relationship.extract_options!.dup relationship_name = relationship.first - relationship = source._relationships[relationship_name] + relationship = source._relationship(relationship_name) relationship._routed = true @@ -248,7 +248,7 @@ def jsonapi_related_resources(*relationship) options = relationship.extract_options!.dup relationship_name = relationship.first - relationship = source._relationships[relationship_name] + relationship = source._relationship(relationship_name) relationship._routed = true diff --git a/lib/jsonapi/simple_resource.rb b/lib/jsonapi/simple_resource.rb new file mode 100644 index 000000000..b5bfe5edd --- /dev/null +++ b/lib/jsonapi/simple_resource.rb @@ -0,0 +1,11 @@ +require 'jsonapi/callbacks' +require 'jsonapi/configuration' + +module JSONAPI + class SimpleResource + include ResourceCommon + root_resource + abstract + immutable + end +end diff --git a/lib/tasks/check_upgrade.rake b/lib/tasks/check_upgrade.rake index 34ddef4a6..6e6543ebb 100644 --- a/lib/tasks/check_upgrade.rake +++ b/lib/tasks/check_upgrade.rake @@ -7,7 +7,7 @@ namespace :jsonapi do task :check_upgrade => :environment do Rails.application.eager_load! - resource_klasses = ObjectSpace.each_object(Class).select { |klass| klass < JSONAPI::Resource} + resource_klasses = ObjectSpace.each_object(Class).select { |klass| klass.include?(JSONAPI::ResourceCommon)} puts "Checking #{resource_klasses.count} resources" diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index e37014caf..4d576c491 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -5,12 +5,6 @@ def set_content_type_header! end class PostsControllerTest < ActionController::TestCase - def setup - super - JSONAPI.configuration.raise_if_parameters_not_allowed = true - JSONAPI.configuration.always_include_to_one_linkage_data = false - end - def test_links_include_relative_root Rails.application.config.relative_url_root = '/subdir' assert_cacheable_get :index @@ -88,165 +82,144 @@ def test_accept_header_not_jsonapi end def test_exception_class_allowlist - original_allowlist = JSONAPI.configuration.exception_class_allowlist.dup - $PostProcessorRaisesErrors = true - # test that the operations dispatcher rescues the error when it - # has not been added to the exception_class_allowlist - assert_cacheable_get :index - assert_response 500 + with_jsonapi_config_changes do + $PostProcessorRaisesErrors = true - # test that the operations dispatcher does not rescue the error when it - # has been added to the exception_class_allowlist - JSONAPI.configuration.exception_class_allowlist << PostsController::SpecialError - assert_cacheable_get :index - assert_response 403 - ensure - $PostProcessorRaisesErrors = false - JSONAPI.configuration.exception_class_allowlist = original_allowlist + # test that the operations dispatcher rescues the error when it + # has not been added to the exception_class_allowlist + assert_cacheable_get :index + assert_response 500 + + # test that the operations dispatcher does not rescue the error when it + # has been added to the exception_class_allowlist + JSONAPI.configuration.exception_class_allowlist << 'PostsController::SpecialError' + assert_cacheable_get :index + assert_response 403 + end end def test_allow_all_exceptions - original_config = JSONAPI.configuration.allow_all_exceptions - $PostProcessorRaisesErrors = true - assert_cacheable_get :index - assert_response 500 - - JSONAPI.configuration.allow_all_exceptions = true - assert_cacheable_get :index - assert_response 403 - ensure - $PostProcessorRaisesErrors = false - JSONAPI.configuration.allow_all_exceptions = original_config - end + with_jsonapi_config_changes do + $PostProcessorRaisesErrors = true - def test_whitelist_all_exceptions - original_config = JSONAPI.configuration.allow_all_exceptions - $PostProcessorRaisesErrors = true - assert_cacheable_get :index - assert_response 500 + JSONAPI.configuration.exception_class_allowlist = [] + JSONAPI.configuration.allow_all_exceptions = false + assert_cacheable_get :index + assert_response 500 - JSONAPI.configuration.whitelist_all_exceptions = true - assert_cacheable_get :index - assert_response 403 - ensure - $PostProcessorRaisesErrors = false - JSONAPI.configuration.whitelist_all_exceptions = original_config + JSONAPI.configuration.allow_all_exceptions = true + assert_cacheable_get :index + assert_response 403 + end end def test_exception_added_to_request_env - original_config = JSONAPI.configuration.allow_all_exceptions - $PostProcessorRaisesErrors = true - refute @request.env['action_dispatch.exception'] - assert_cacheable_get :index - assert @request.env['action_dispatch.exception'] + with_jsonapi_config_changes do + $PostProcessorRaisesErrors = true - JSONAPI.configuration.allow_all_exceptions = true - assert_cacheable_get :index - assert @request.env['action_dispatch.exception'] - ensure - $PostProcessorRaisesErrors = false - JSONAPI.configuration.allow_all_exceptions = original_config + JSONAPI.configuration.exception_class_allowlist = [] + + refute @request.env['action_dispatch.exception'] + assert_cacheable_get :index + assert @request.env['action_dispatch.exception'] + + JSONAPI.configuration.allow_all_exceptions = true + assert_cacheable_get :index + assert @request.env['action_dispatch.exception'] + end end def test_exception_includes_backtrace_when_enabled - original_config = JSONAPI.configuration.include_backtraces_in_errors - $PostProcessorRaisesErrors = true + with_jsonapi_config_changes do + $PostProcessorRaisesErrors = true - JSONAPI.configuration.include_backtraces_in_errors = true - assert_cacheable_get :index - assert_response 500 - assert_includes @response.body, '"backtrace"', "expected backtrace in error body" - - JSONAPI.configuration.include_backtraces_in_errors = false - assert_cacheable_get :index - assert_response 500 - refute_includes @response.body, '"backtrace"', "expected backtrace in error body" + JSONAPI.configuration.exception_class_allowlist = [] + JSONAPI.configuration.include_backtraces_in_errors = true + assert_cacheable_get :index + assert_response 500 + assert_includes @response.body, '"backtrace"', "expected backtrace in error body" - ensure - $PostProcessorRaisesErrors = false - JSONAPI.configuration.include_backtraces_in_errors = original_config + JSONAPI.configuration.include_backtraces_in_errors = false + assert_cacheable_get :index + assert_response 500 + refute_includes @response.body, '"backtrace"', "expected backtrace in error body" + end end def test_exception_includes_application_backtrace_when_enabled - original_config = JSONAPI.configuration.include_application_backtraces_in_errors - $PostProcessorRaisesErrors = true + with_jsonapi_config_changes do + $PostProcessorRaisesErrors = true - JSONAPI.configuration.include_application_backtraces_in_errors = true - assert_cacheable_get :index - assert_response 500 - assert_includes @response.body, '"application_backtrace"', "expected application backtrace in error body" + JSONAPI.configuration.include_application_backtraces_in_errors = true + JSONAPI.configuration.exception_class_allowlist = [] - JSONAPI.configuration.include_application_backtraces_in_errors = false - assert_cacheable_get :index - assert_response 500 - refute_includes @response.body, '"application_backtrace"', "expected application backtrace in error body" + assert_cacheable_get :index + assert_response 500 + assert_includes @response.body, '"application_backtrace"', "expected application backtrace in error body" - ensure - $PostProcessorRaisesErrors = false - JSONAPI.configuration.include_application_backtraces_in_errors = original_config + JSONAPI.configuration.include_application_backtraces_in_errors = false + assert_cacheable_get :index + assert_response 500 + refute_includes @response.body, '"application_backtrace"', "expected application backtrace in error body" + end end def test_on_server_error_block_callback_with_exception - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.exception_class_allowlist = [] - $PostProcessorRaisesErrors = true + with_jsonapi_config_changes do + $PostProcessorRaisesErrors = true + JSONAPI.configuration.exception_class_allowlist = [] - @controller.class.instance_variable_set(:@callback_message, "none") - BaseController.on_server_error do - @controller.class.instance_variable_set(:@callback_message, "Sent from block") - end + @controller.class.instance_variable_set(:@callback_message, "none") + BaseController.on_server_error do + @controller.class.instance_variable_set(:@callback_message, "Sent from block") + end - assert_cacheable_get :index - assert_equal @controller.class.instance_variable_get(:@callback_message), "Sent from block" + assert_cacheable_get :index + assert_equal @controller.class.instance_variable_get(:@callback_message), "Sent from block" - # test that it renders the default server error response - assert_equal "Internal Server Error", json_response['errors'][0]['title'] - assert_equal "Internal Server Error", json_response['errors'][0]['detail'] - ensure - $PostProcessorRaisesErrors = false - JSONAPI.configuration = original_config + # test that it renders the default server error response + assert_equal "Internal Server Error", json_response['errors'][0]['title'] + assert_equal "Internal Server Error", json_response['errors'][0]['detail'] + end end def test_on_server_error_method_callback_with_exception - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.exception_class_allowlist = [] - $PostProcessorRaisesErrors = true + with_jsonapi_config_changes do + $PostProcessorRaisesErrors = true - #ignores methods that don't exist - @controller.class.on_server_error :set_callback_message, :a_bogus_method - @controller.class.instance_variable_set(:@callback_message, "none") + JSONAPI.configuration.exception_class_allowlist = [] - assert_cacheable_get :index - assert_equal @controller.class.instance_variable_get(:@callback_message), "Sent from method" + # ignores methods that don't exist + @controller.class.on_server_error :set_callback_message, :a_bogus_method + @controller.class.instance_variable_set(:@callback_message, "none") - # test that it renders the default server error response - assert_equal "Internal Server Error", json_response['errors'][0]['title'] - ensure - $PostProcessorRaisesErrors = false - JSONAPI.configuration = original_config + assert_cacheable_get :index + assert_equal @controller.class.instance_variable_get(:@callback_message), "Sent from method" + + # test that it renders the default server error response + assert_equal "Internal Server Error", json_response['errors'][0]['title'] + end end def test_on_server_error_method_callback_with_exception_on_serialize - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.exception_class_allowlist = [] - $PostSerializerRaisesErrors = true + with_jsonapi_config_changes do + $PostProcessorRaisesErrors = true - #ignores methods that don't exist - @controller.class.on_server_error :set_callback_message, :a_bogus_method - @controller.class.instance_variable_set(:@callback_message, "none") + JSONAPI.configuration.exception_class_allowlist = [] - assert_cacheable_get :index - assert_equal "Sent from method", @controller.class.instance_variable_get(:@callback_message) + # ignores methods that don't exist + @controller.class.on_server_error :set_callback_message, :a_bogus_method + @controller.class.instance_variable_set(:@callback_message, "none") - # test that it renders the default server error response - assert_equal "Internal Server Error", json_response['errors'][0]['title'] - ensure - $PostSerializerRaisesErrors = false - JSONAPI.configuration = original_config + assert_cacheable_get :index + assert_equal "Sent from method", @controller.class.instance_variable_get(:@callback_message) + + # test that it renders the default server error response + assert_equal "Internal Server Error", json_response['errors'][0]['title'] + end end def test_on_server_error_callback_without_exception - callback = Proc.new { @controller.class.instance_variable_set(:@callback_message, "Sent from block") } @controller.class.on_server_error callback @controller.class.instance_variable_set(:@callback_message, "none") @@ -256,8 +229,6 @@ def test_on_server_error_callback_without_exception # test that it does not render error assert json_response.key?('data') - ensure - $PostProcessorRaisesErrors = false end def test_posts_index_include @@ -317,23 +288,24 @@ def test_index_filter_by_ids_and_include_related_different_type end def test_index_filter_not_allowed - JSONAPI.configuration.allow_filter = false - assert_cacheable_get :index, params: {filter: {id: '1'}} - assert_response :bad_request - ensure - JSONAPI.configuration.allow_filter = true + with_jsonapi_config_changes do + JSONAPI.configuration.allow_filter = false + assert_cacheable_get :index, params: { filter: { id: '1' } } + assert_response :bad_request + end end def test_index_include_one_level_query_count - assert_query_count(4) do + assert_query_count(testing_v10? ? 4 : 2) do assert_cacheable_get :index, params: {include: 'author'} end + assert_response :success end def test_index_include_two_levels_query_count - assert_query_count(6) do - assert_cacheable_get :index, params: {include: 'author,author.comments'} + assert_query_count(testing_v10? ? 6 : 3) do + assert_cacheable_get :index, params: { include: 'author,author.comments' } end assert_response :success end @@ -383,7 +355,7 @@ def test_index_filter_by_ids_and_fields_2 end def test_filter_relationship_single - assert_query_count(2) do + assert_query_count(testing_v10? ? 2 : 1) do assert_cacheable_get :index, params: {filter: {tags: '505,501'}} end assert_response :success @@ -394,8 +366,8 @@ def test_filter_relationship_single end def test_filter_relationships_multiple - assert_query_count(2) do - assert_cacheable_get :index, params: {filter: {tags: '505,501', comments: '3'}} + assert_query_count(testing_v10? ? 2 : 1) do + assert_cacheable_get :index, params: { filter: { tags: '505,501', comments: '3' } } end assert_response :success assert_equal 1, json_response['data'].size @@ -549,11 +521,11 @@ def test_invalid_sort_param end def test_show_single_with_sort_disallowed - JSONAPI.configuration.allow_sort = false - assert_cacheable_get :index, params: {sort: 'title,body'} - assert_response :bad_request - ensure - JSONAPI.configuration.allow_sort = true + with_jsonapi_config_changes do + JSONAPI.configuration.allow_sort = false + assert_cacheable_get :index, params: { sort: 'title,body' } + assert_response :bad_request + end end def test_excluded_sort_param @@ -573,21 +545,21 @@ def test_show_single_no_includes end def test_show_does_not_include_records_count_in_meta - JSONAPI.configuration.top_level_meta_include_record_count = true - assert_cacheable_get :show, params: { id: Post.first.id } - assert_response :success - assert_nil json_response['meta'] - ensure - JSONAPI.configuration.top_level_meta_include_record_count = false + with_jsonapi_config_changes do + JSONAPI.configuration.top_level_meta_include_record_count = true + assert_cacheable_get :show, params: { id: Post.first.id } + assert_response :success + assert_nil json_response['meta'] + end end def test_show_does_not_include_pages_count_in_meta - JSONAPI.configuration.top_level_meta_include_page_count = true - assert_cacheable_get :show, params: { id: Post.first.id } - assert_response :success - assert_nil json_response['meta'] - ensure - JSONAPI.configuration.top_level_meta_include_page_count = false + with_jsonapi_config_changes do + JSONAPI.configuration.top_level_meta_include_page_count = true + assert_cacheable_get :show, params: { id: Post.first.id } + assert_response :success + assert_nil json_response['meta'] + end end def test_show_single_with_has_one_include_included_exists @@ -632,38 +604,37 @@ def test_includes_for_empty_relationships_shows_but_are_empty end def test_show_single_with_include_disallowed - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.allow_include = false - assert_cacheable_get :show, params: {id: '1', include: 'comments'} - assert_response :bad_request - ensure - JSONAPI.configuration = original_config + with_jsonapi_config_changes do + JSONAPI.configuration.allow_include = false + assert_cacheable_get :show, params: { id: '1', include: 'comments' } + assert_response :bad_request + end end def test_show_single_include_linkage - JSONAPI.configuration.always_include_to_one_linkage_data = true - - assert_cacheable_get :show, params: {id: '17'} - assert_response :success - assert json_response['data']['relationships']['author'].has_key?('data'), 'data key should exist for empty has_one relationship' - assert_nil json_response['data']['relationships']['author']['data'], 'Data should be null' - refute json_response['data']['relationships']['tags'].has_key?('data'), 'data key should not exist for empty has_many relationship if not included' + with_jsonapi_config_changes do + JSONAPI.configuration.always_include_to_one_linkage_data = true - ensure - JSONAPI.configuration.always_include_to_one_linkage_data = false + assert_cacheable_get :show, params: { id: '17' } + assert_response :success + assert json_response['data']['relationships']['author'].has_key?('data'), 'data key should exist for empty has_one relationship' + assert_nil json_response['data']['relationships']['author']['data'], 'Data should be null' + refute json_response['data']['relationships']['tags'].has_key?('data'), 'data key should not exist for empty has_many relationship if not included' + end end def test_index_single_include_linkage - JSONAPI.configuration.always_include_to_one_linkage_data = true - - assert_cacheable_get :index, params: { filter: { id: '17'} } - assert_response :success - assert json_response['data'][0]['relationships']['author'].has_key?('data'), 'data key should exist for empty has_one relationship' - assert_nil json_response['data'][0]['relationships']['author']['data'], 'Data should be null' - refute json_response['data'][0]['relationships']['tags'].has_key?('data'), 'data key should not exist for empty has_many relationship if not included' + with_jsonapi_config_changes do + JSONAPI.configuration.always_include_to_one_linkage_data = true + JSONAPI.configuration.default_processor_klass = nil + JSONAPI.configuration.exception_class_allowlist = [] - ensure - JSONAPI.configuration.always_include_to_one_linkage_data = false + assert_cacheable_get :index, params: { filter: { id: '17' } } + assert_response :success + assert json_response['data'][0]['relationships']['author'].has_key?('data'), 'data key should exist for empty has_one relationship' + assert_nil json_response['data'][0]['relationships']['author']['data'], 'Data should be null' + refute json_response['data'][0]['relationships']['tags'].has_key?('data'), 'data key should not exist for empty has_many relationship if not included' + end end def test_show_single_with_fields @@ -773,18 +744,18 @@ def test_create_link_to_missing_object def test_create_bad_relationship_array set_content_type_header! put :create, params: - { - data: { - type: 'posts', - attributes: { - title: 'A poorly formed new Post' - }, - relationships: { - author: {data: {type: 'people', id: '1003'}}, - tags: [] - } - } + { + data: { + type: 'posts', + attributes: { + title: 'A poorly formed new Post' + }, + relationships: { + author: { data: { type: 'people', id: '1003' } }, + tags: [] + } } + } assert_response :bad_request assert_match /Data is not a valid Links Object./, response.body @@ -813,42 +784,42 @@ def test_create_extra_param end def test_create_extra_param_allow_extra_params - JSONAPI.configuration.raise_if_parameters_not_allowed = false + with_jsonapi_config_changes do + JSONAPI.configuration.raise_if_parameters_not_allowed = false - set_content_type_header! - post :create, params: - { - data: { - type: 'posts', - id: 'my_id', - attributes: { - asdfg: 'aaaa', - title: 'JR is Great', - body: 'JSONAPIResources is the greatest thing since unsliced bread.' + set_content_type_header! + post :create, params: + { + data: { + type: 'posts', + id: 'my_id', + attributes: { + asdfg: 'aaaa', + title: 'JR is Great', + body: 'JSONAPIResources is the greatest thing since unsliced bread.' + }, + relationships: { + author: { data: { type: 'people', id: '1003' } } + } }, - relationships: { - author: {data: {type: 'people', id: '1003'}} - } - }, - include: 'author' - } - - assert_response :created - assert json_response['data'].is_a?(Hash) - assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] - assert_equal 'JR is Great', json_response['data']['attributes']['title'] - assert_equal 'JSONAPIResources is the greatest thing since unsliced bread.', json_response['data']['attributes']['body'] + include: 'author' + } - assert_equal 2, json_response['meta']["warnings"].count - assert_equal "Param not allowed", json_response['meta']["warnings"][0]["title"] - assert_equal "id is not allowed.", json_response['meta']["warnings"][0]["detail"] - assert_equal '105', json_response['meta']["warnings"][0]["code"] - assert_equal "Param not allowed", json_response['meta']["warnings"][1]["title"] - assert_equal "asdfg is not allowed.", json_response['meta']["warnings"][1]["detail"] - assert_equal '105', json_response['meta']["warnings"][1]["code"] - assert_equal json_response['data']['links']['self'], response.location - ensure - JSONAPI.configuration.raise_if_parameters_not_allowed = true + assert_response :created + assert json_response['data'].is_a?(Hash) + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] + assert_equal 'JR is Great', json_response['data']['attributes']['title'] + assert_equal 'JSONAPIResources is the greatest thing since unsliced bread.', json_response['data']['attributes']['body'] + + assert_equal 2, json_response['meta']["warnings"].count + assert_equal "Param not allowed", json_response['meta']["warnings"][0]["title"] + assert_equal "id is not allowed.", json_response['meta']["warnings"][0]["detail"] + assert_equal '105', json_response['meta']["warnings"][0]["code"] + assert_equal "Param not allowed", json_response['meta']["warnings"][1]["title"] + assert_equal "asdfg is not allowed.", json_response['meta']["warnings"][1]["detail"] + assert_equal '105', json_response['meta']["warnings"][1]["code"] + assert_equal json_response['data']['links']['self'], response.location + end end def test_create_with_invalid_data @@ -995,40 +966,39 @@ def test_create_simple_unpermitted_attributes end def test_create_simple_unpermitted_attributes_allow_extra_params - JSONAPI.configuration.raise_if_parameters_not_allowed = false + with_jsonapi_config_changes do + JSONAPI.configuration.raise_if_parameters_not_allowed = false - set_content_type_header! - post :create, params: - { - data: { - type: 'posts', - attributes: { - title: 'JR is Great', - subject: 'JR is SUPER Great', - body: 'JSONAPIResources is the greatest thing since unsliced bread.' + set_content_type_header! + post :create, params: + { + data: { + type: 'posts', + attributes: { + title: 'JR is Great', + subject: 'JR is SUPER Great', + body: 'JSONAPIResources is the greatest thing since unsliced bread.' + }, + relationships: { + author: { data: { type: 'people', id: '1003' } } + } }, - relationships: { - author: {data: {type: 'people', id: '1003'}} - } - }, - include: 'author' - } - - assert_response :created - assert json_response['data'].is_a?(Hash) - assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] - assert_equal 'JR is Great', json_response['data']['attributes']['title'] - assert_equal 'JR is Great', json_response['data']['attributes']['subject'] - assert_equal 'JSONAPIResources is the greatest thing since unsliced bread.', json_response['data']['attributes']['body'] - + include: 'author' + } - assert_equal 1, json_response['meta']["warnings"].count - assert_equal "Param not allowed", json_response['meta']["warnings"][0]["title"] - assert_equal "subject is not allowed.", json_response['meta']["warnings"][0]["detail"] - assert_equal '105', json_response['meta']["warnings"][0]["code"] - assert_equal json_response['data']['links']['self'], response.location - ensure - JSONAPI.configuration.raise_if_parameters_not_allowed = true + assert_response :created + assert json_response['data'].is_a?(Hash) + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] + assert_equal 'JR is Great', json_response['data']['attributes']['title'] + assert_equal 'JR is Great', json_response['data']['attributes']['subject'] + assert_equal 'JSONAPIResources is the greatest thing since unsliced bread.', json_response['data']['attributes']['body'] + + assert_equal 1, json_response['meta']["warnings"].count + assert_equal "Param not allowed", json_response['meta']["warnings"][0]["title"] + assert_equal "subject is not allowed.", json_response['meta']["warnings"][0]["detail"] + assert_equal '105', json_response['meta']["warnings"][0]["code"] + assert_equal json_response['data']['links']['self'], response.location + end end def test_create_with_links_to_many_type_ids @@ -1164,45 +1134,44 @@ def test_update_with_internal_server_error end def test_update_with_links_allow_extra_params - JSONAPI.configuration.raise_if_parameters_not_allowed = false + with_jsonapi_config_changes do + JSONAPI.configuration.raise_if_parameters_not_allowed = false - set_content_type_header! - javascript = Section.find_by(name: 'javascript') + set_content_type_header! + javascript = Section.find_by(name: 'javascript') - put :update, params: - { - id: 3, - data: { - id: '3', - type: 'posts', - attributes: { - title: 'A great new Post', - subject: 'A great new Post', + put :update, params: + { + id: 3, + data: { + id: '3', + type: 'posts', + attributes: { + title: 'A great new Post', + subject: 'A great new Post', + }, + relationships: { + section: { data: { type: 'sections', id: "#{javascript.id}" } }, + tags: { data: [{ type: 'tags', id: 503 }, { type: 'tags', id: 504 }] } + } }, - relationships: { - section: {data: {type: 'sections', id: "#{javascript.id}"}}, - tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} - } - }, - include: 'tags,author,section' - } - - assert_response :success - assert json_response['data'].is_a?(Hash) - assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] - assert_equal javascript.id.to_s, json_response['data']['relationships']['section']['data']['id'] - assert_equal 'A great new Post', json_response['data']['attributes']['title'] - assert_equal 'AAAA', json_response['data']['attributes']['body'] - assert matches_array?([{'type' => 'tags', 'id' => '503'}, {'type' => 'tags', 'id' => '504'}], - json_response['data']['relationships']['tags']['data']) - + include: 'tags,author,section' + } - assert_equal 1, json_response['meta']["warnings"].count - assert_equal "Param not allowed", json_response['meta']["warnings"][0]["title"] - assert_equal "subject is not allowed.", json_response['meta']["warnings"][0]["detail"] - assert_equal '105', json_response['meta']["warnings"][0]["code"] - ensure - JSONAPI.configuration.raise_if_parameters_not_allowed = true + assert_response :success + assert json_response['data'].is_a?(Hash) + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] + assert_equal javascript.id.to_s, json_response['data']['relationships']['section']['data']['id'] + assert_equal 'A great new Post', json_response['data']['attributes']['title'] + assert_equal 'AAAA', json_response['data']['attributes']['body'] + assert matches_array?([{ 'type' => 'tags', 'id' => '503' }, { 'type' => 'tags', 'id' => '504' }], + json_response['data']['relationships']['tags']['data']) + + assert_equal 1, json_response['meta']["warnings"].count + assert_equal "Param not allowed", json_response['meta']["warnings"][0]["title"] + assert_equal "subject is not allowed.", json_response['meta']["warnings"][0]["detail"] + assert_equal '105', json_response['meta']["warnings"][0]["code"] + end end def test_update_remove_links @@ -1517,19 +1486,19 @@ def test_create_relationship_to_many_join_table end def test_create_relationship_to_many_join_table_reflect - JSONAPI.configuration.use_relationship_reflection = true - set_content_type_header! - post_object = Post.find(15) - assert_equal 5, post_object.tags.collect { |tag| tag.id }.length - - put :update_relationship, params: {post_id: 15, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 503}, {type: 'tags', id: 504}]} - - assert_response :no_content - post_object = Post.find(15) - assert_equal 3, post_object.tags.collect { |tag| tag.id }.length - assert matches_array? [502, 503, 504], post_object.tags.collect { |tag| tag.id } - ensure - JSONAPI.configuration.use_relationship_reflection = false + with_jsonapi_config_changes do + JSONAPI.configuration.use_relationship_reflection = true + set_content_type_header! + post_object = Post.find(15) + assert_equal 5, post_object.tags.collect { |tag| tag.id }.length + + put :update_relationship, params: { post_id: 15, relationship: 'tags', data: [{ type: 'tags', id: 502 }, { type: 'tags', id: 503 }, { type: 'tags', id: 504 }] } + + assert_response :no_content + post_object = Post.find(15) + assert_equal 3, post_object.tags.collect { |tag| tag.id }.length + assert matches_array? [502, 503, 504], post_object.tags.collect { |tag| tag.id } + end end def test_create_relationship_to_many_mismatched_type @@ -1565,63 +1534,63 @@ def test_create_relationship_to_many_missing_data end def test_create_relationship_to_many_join_table_no_reflection - JSONAPI.configuration.use_relationship_reflection = false - set_content_type_header! - p = Post.find(4) - assert_equal [], p.tag_ids + with_jsonapi_config_changes do + JSONAPI.configuration.use_relationship_reflection = false + set_content_type_header! + p = Post.find(4) + assert_equal [], p.tag_ids - post :create_relationship, params: {post_id: 4, relationship: 'tags', data: [{type: 'tags', id: 501}, {type: 'tags', id: 502}, {type: 'tags', id: 503}]} - assert_response :no_content + post :create_relationship, params: { post_id: 4, relationship: 'tags', data: [{ type: 'tags', id: 501 }, { type: 'tags', id: 502 }, { type: 'tags', id: 503 }] } + assert_response :no_content - p.reload - assert_equal [501,502,503], p.tag_ids - ensure - JSONAPI.configuration.use_relationship_reflection = false + p.reload + assert_equal [501, 502, 503], p.tag_ids + end end def test_create_relationship_to_many_join_table_reflection - JSONAPI.configuration.use_relationship_reflection = true - set_content_type_header! - p = Post.find(4) - assert_equal [], p.tag_ids + with_jsonapi_config_changes do + JSONAPI.configuration.use_relationship_reflection = true + set_content_type_header! + p = Post.find(4) + assert_equal [], p.tag_ids - post :create_relationship, params: {post_id: 4, relationship: 'tags', data: [{type: 'tags', id: 501}, {type: 'tags', id: 502}, {type: 'tags', id: 503}]} - assert_response :no_content + post :create_relationship, params: { post_id: 4, relationship: 'tags', data: [{ type: 'tags', id: 501 }, { type: 'tags', id: 502 }, { type: 'tags', id: 503 }] } + assert_response :no_content - p.reload - assert_equal [501,502,503], p.tag_ids - ensure - JSONAPI.configuration.use_relationship_reflection = false + p.reload + assert_equal [501, 502, 503], p.tag_ids + end end def test_create_relationship_to_many_no_reflection - JSONAPI.configuration.use_relationship_reflection = false - set_content_type_header! - p = Post.find(4) - assert_equal [], p.comment_ids + with_jsonapi_config_changes do + JSONAPI.configuration.use_relationship_reflection = false + set_content_type_header! + p = Post.find(4) + assert_equal [], p.comment_ids - post :create_relationship, params: {post_id: 4, relationship: 'comments', data: [{type: 'comments', id: 7}, {type: 'comments', id: 8}]} + post :create_relationship, params: { post_id: 4, relationship: 'comments', data: [{ type: 'comments', id: 7 }, { type: 'comments', id: 8 }] } - assert_response :no_content - p.reload - assert_equal [7,8], p.comment_ids - ensure - JSONAPI.configuration.use_relationship_reflection = false + assert_response :no_content + p.reload + assert_equal [7, 8], p.comment_ids + end end def test_create_relationship_to_many_reflection - JSONAPI.configuration.use_relationship_reflection = true - set_content_type_header! - p = Post.find(4) - assert_equal [], p.comment_ids + with_jsonapi_config_changes do + JSONAPI.configuration.use_relationship_reflection = true + set_content_type_header! + p = Post.find(4) + assert_equal [], p.comment_ids - post :create_relationship, params: {post_id: 4, relationship: 'comments', data: [{type: 'comments', id: 7}, {type: 'comments', id: 8}]} + post :create_relationship, params: { post_id: 4, relationship: 'comments', data: [{ type: 'comments', id: 7 }, { type: 'comments', id: 8 }] } - assert_response :no_content - p.reload - assert_equal [7,8], p.comment_ids - ensure - JSONAPI.configuration.use_relationship_reflection = false + assert_response :no_content + p.reload + assert_equal [7, 8], p.comment_ids + end end def test_create_relationship_to_many_join_table_record_exists @@ -1684,6 +1653,7 @@ def test_delete_relationship_to_many_with_relationship_url_not_matching_type set_content_type_header! # Reflection turned off since tags doesn't have the inverse relationship PostResource.has_many :special_tags, relation_name: :special_tags, class_name: "Tag", reflect: false + post :create_relationship, params: {post_id: 14, relationship: 'special_tags', data: [{type: 'tags', id: 502}]} #check the relationship was created successfully @@ -1802,35 +1772,34 @@ def test_update_extra_param_in_links end def test_update_extra_param_in_links_allow_extra_params - JSONAPI.configuration.raise_if_parameters_not_allowed = false - JSONAPI.configuration.use_text_errors = true + with_jsonapi_config_changes do + JSONAPI.configuration.raise_if_parameters_not_allowed = false + JSONAPI.configuration.use_text_errors = true - set_content_type_header! - javascript = Section.find_by(name: 'javascript') + set_content_type_header! + javascript = Section.find_by(name: 'javascript') - put :update, params: - { - id: 3, - data: { - type: 'posts', - id: '3', - attributes: { - title: 'A great new Post' - }, - relationships: { - asdfg: 'aaaa' + put :update, params: + { + id: 3, + data: { + type: 'posts', + id: '3', + attributes: { + title: 'A great new Post' + }, + relationships: { + asdfg: 'aaaa' + } } } - } - assert_response :success - assert_equal "A great new Post", json_response["data"]["attributes"]["title"] - assert_equal "Param not allowed", json_response["meta"]["warnings"][0]["title"] - assert_equal "asdfg is not allowed.", json_response["meta"]["warnings"][0]["detail"] - assert_equal "PARAM_NOT_ALLOWED", json_response["meta"]["warnings"][0]["code"] - ensure - JSONAPI.configuration.raise_if_parameters_not_allowed = true - JSONAPI.configuration.use_text_errors = false + assert_response :success + assert_equal "A great new Post", json_response["data"]["attributes"]["title"] + assert_equal "Param not allowed", json_response["meta"]["warnings"][0]["title"] + assert_equal "asdfg is not allowed.", json_response["meta"]["warnings"][0]["detail"] + assert_equal "PARAM_NOT_ALLOWED", json_response["meta"]["warnings"][0]["code"] + end end def test_update_missing_param @@ -2126,46 +2095,46 @@ def test_index_related_resources_has_many_filtered class TagsControllerTest < ActionController::TestCase def test_tags_index - assert_cacheable_get :index, params: {filter: {id: '506,507,508,509'}} + assert_cacheable_get :index, params: { filter: { id: '506,507,508,509' } } assert_response :success assert_equal 4, json_response['data'].size end def test_tags_index_include_nested_tree - assert_cacheable_get :index, params: {filter: {id: '506,508,509'}, include: 'posts.tags,posts.author.posts'} + assert_cacheable_get :index, params: { filter: { id: '506,508,509' }, include: 'posts.tags,posts.author.posts' } assert_response :success assert_equal 3, json_response['data'].size assert_equal 4, json_response['included'].size end def test_tags_show_multiple - assert_cacheable_get :show, params: {id: '506,507,508,509'} + assert_cacheable_get :show, params: { id: '506,507,508,509' } assert_response :bad_request assert_match /506,507,508,509 is not a valid value for id/, response.body end def test_tags_show_multiple_with_include - assert_cacheable_get :show, params: {id: '506,507,508,509', include: 'posts.tags,posts.author.posts'} + assert_cacheable_get :show, params: { id: '506,507,508,509', include: 'posts.tags,posts.author.posts' } assert_response :bad_request assert_match /506,507,508,509 is not a valid value for id/, response.body end def test_tags_show_multiple_with_nonexistent_ids - assert_cacheable_get :show, params: {id: '506,5099,509,50100'} + assert_cacheable_get :show, params: { id: '506,5099,509,50100' } assert_response :bad_request assert_match /506,5099,509,50100 is not a valid value for id/, response.body end def test_tags_show_multiple_with_nonexistent_ids_at_the_beginning - assert_cacheable_get :show, params: {id: '5099,509,50100'} + assert_cacheable_get :show, params: { id: '5099,509,50100' } assert_response :bad_request assert_match /5099,509,50100 is not a valid value for id/, response.body end def test_nested_includes_sort - assert_cacheable_get :index, params: {filter: {id: '506,507,508,509'}, - include: 'posts.tags,posts.author.posts', - sort: 'name'} + assert_cacheable_get :index, params: { filter: { id: '506,507,508,509' }, + include: 'posts.tags,posts.author.posts', + sort: 'name' } assert_response :success assert_equal 4, json_response['data'].size assert_equal 3, json_response['included'].size @@ -2180,36 +2149,38 @@ def test_pictures_index end def test_pictures_index_with_polymorphic_include_one_level - assert_cacheable_get :index, params: {include: 'imageable'} + assert_cacheable_get :index, params: { include: 'imageable' } assert_response :success assert_equal 8, json_response['data'].try(:size) assert_equal 5, json_response['included'].try(:size) end def test_pictures_index_with_polymorphic_to_one_linkage - JSONAPI.configuration.always_include_to_one_linkage_data = true - assert_cacheable_get :index - assert_response :success - assert_equal 8, json_response['data'].try(:size) - assert_equal '3', json_response['data'][2]['id'] - assert_nil json_response['data'][2]['relationships']['imageable']['data'] - assert_equal 'products', json_response['data'][0]['relationships']['imageable']['data']['type'] - assert_equal '1', json_response['data'][0]['relationships']['imageable']['data']['id'] - ensure - JSONAPI.configuration.always_include_to_one_linkage_data = false + with_jsonapi_config_changes do + JSONAPI.configuration.always_include_to_one_linkage_data = true + assert_cacheable_get :index + assert_response :success + assert_equal 8, json_response['data'].try(:size) + assert_equal '3', json_response['data'][2]['id'] + assert_nil json_response['data'][2]['relationships']['imageable']['data'] + + assert_equal '1', json_response['data'][0]['id'] + assert_equal 'products', json_response['data'][0]['relationships']['imageable']['data']['type'] + assert_equal '1', json_response['data'][0]['relationships']['imageable']['data']['id'] + end end def test_pictures_index_with_polymorphic_include_one_level_to_one_linkages - JSONAPI.configuration.always_include_to_one_linkage_data = true - assert_cacheable_get :index, params: {include: 'imageable'} - assert_response :success - assert_equal 8, json_response['data'].try(:size) - assert_equal 5, json_response['included'].try(:size) - assert_nil json_response['data'][2]['relationships']['imageable']['data'] - assert_equal 'products', json_response['data'][0]['relationships']['imageable']['data']['type'] - assert_equal '1', json_response['data'][0]['relationships']['imageable']['data']['id'] - ensure - JSONAPI.configuration.always_include_to_one_linkage_data = false + with_jsonapi_config_changes do + JSONAPI.configuration.always_include_to_one_linkage_data = true + assert_cacheable_get :index, params: { include: 'imageable' } + assert_response :success + assert_equal 8, json_response['data'].try(:size) + assert_equal 5, json_response['included'].try(:size) + assert_nil json_response['data'][2]['relationships']['imageable']['data'] + assert_equal 'products', json_response['data'][0]['relationships']['imageable']['data']['type'] + assert_equal '1', json_response['data'][0]['relationships']['imageable']['data']['id'] + end end def test_update_relationship_to_one_polymorphic @@ -2223,7 +2194,7 @@ def test_update_relationship_to_one_polymorphic end def test_pictures_index_with_filter_documents - assert_cacheable_get :index, params: {include: 'imageable', filter: {'imageable#documents.name': 'Management Through the Years'}} + assert_cacheable_get :index, params: { include: 'imageable', filter: { 'imageable#documents.name': 'Management Through the Years' } } assert_response :success assert_equal 3, json_response['data'].try(:size) assert_equal 1, json_response['included'].try(:size) @@ -2238,7 +2209,7 @@ def test_documents_index end def test_documents_index_with_polymorphic_include_one_level - assert_cacheable_get :index, params: {include: 'pictures'} + assert_cacheable_get :index, params: { include: 'pictures' } assert_response :success assert_equal 5, json_response['data'].size assert_equal 6, json_response['included'].size @@ -2246,226 +2217,256 @@ def test_documents_index_with_polymorphic_include_one_level end class ExpenseEntriesControllerTest < ActionController::TestCase - def setup - JSONAPI.configuration.json_key_format = :camelized_key - end - def test_text_error - JSONAPI.configuration.use_text_errors = true - assert_cacheable_get :index, params: {sort: 'not_in_record'} - assert_response 400 - assert_equal 'INVALID_SORT_CRITERIA', json_response['errors'][0]['code'] - ensure - JSONAPI.configuration.use_text_errors = false + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + JSONAPI.configuration.use_text_errors = true + assert_cacheable_get :index, params: { sort: 'not_in_record' } + assert_response 400 + assert_equal 'INVALID_SORT_CRITERIA', json_response['errors'][0]['code'] + end end def test_expense_entries_index - assert_cacheable_get :index - assert_response :success - assert json_response['data'].is_a?(Array) - assert_equal 2, json_response['data'].size + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + assert_cacheable_get :index + assert_response :success + assert json_response['data'].is_a?(Array) + assert_equal 2, json_response['data'].size + end end def test_expense_entries_show - assert_cacheable_get :show, params: {id: 1} - assert_response :success - assert json_response['data'].is_a?(Hash) + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + assert_cacheable_get :show, params: { id: 1 } + assert_response :success + assert json_response['data'].is_a?(Hash) + end end def test_expense_entries_show_include - assert_cacheable_get :show, params: {id: 1, include: 'isoCurrency,employee'} - assert_response :success - assert json_response['data'].is_a?(Hash) - assert_equal 2, json_response['included'].size + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + assert_cacheable_get :show, params: { id: 1, include: 'isoCurrency,employee' } + assert_response :success + assert json_response['data'].is_a?(Hash) + assert_equal 2, json_response['included'].size + end end def test_expense_entries_show_bad_include_missing_relationship - assert_cacheable_get :show, params: {id: 1, include: 'isoCurrencies,employees'} - assert_response :bad_request - assert_match /isoCurrencies is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail'] + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + assert_cacheable_get :show, params: { id: 1, include: 'isoCurrencies,employees' } + assert_response :bad_request + assert_match /isoCurrencies is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail'] + end end def test_expense_entries_show_bad_include_missing_sub_relationship - assert_cacheable_get :show, params: {id: 1, include: 'isoCurrency,employee.post'} - assert_response :bad_request - assert_match /post is not a valid includable relationship of employees/, json_response['errors'][0]['detail'] + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + assert_cacheable_get :show, params: { id: 1, include: 'isoCurrency,employee.post' } + assert_response :bad_request + assert_match /post is not a valid includable relationship of employees/, json_response['errors'][0]['detail'] + end end def test_invalid_include - assert_cacheable_get :index, params: {include: 'invalid../../../../'} - assert_response :bad_request - assert_match /invalid is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail'] + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + assert_cacheable_get :index, params: { include: 'invalid../../../../' } + assert_response :bad_request + assert_match /invalid is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail'] + end end def test_invalid_include_long_garbage_string - assert_cacheable_get :index, params: {include: 'invalid.foo.bar.dfsdfs,dfsdfs.sdfwe.ewrerw.erwrewrew'} - assert_response :bad_request - assert_match /invalid is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail'] + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + assert_cacheable_get :index, params: { include: 'invalid.foo.bar.dfsdfs,dfsdfs.sdfwe.ewrerw.erwrewrew' } + assert_response :bad_request + assert_match /invalid is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail'] + end end def test_expense_entries_show_fields - assert_cacheable_get :show, params: {id: 1, include: 'isoCurrency,employee', 'fields' => {'expenseEntries' => 'transactionDate'}} - assert_response :success - assert json_response['data'].is_a?(Hash) - assert_equal ['transactionDate'], json_response['data']['attributes'].keys - assert_equal 2, json_response['included'].size + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + assert_cacheable_get :show, params: { id: 1, include: 'isoCurrency,employee', 'fields' => { 'expenseEntries' => 'transactionDate' } } + assert_response :success + assert json_response['data'].is_a?(Hash) + assert_equal ['transactionDate'], json_response['data']['attributes'].keys + assert_equal 2, json_response['included'].size + end end def test_expense_entries_show_fields_type_many - assert_cacheable_get :show, params: {id: 1, include: 'isoCurrency,employee', 'fields' => {'expenseEntries' => 'transactionDate', - 'isoCurrencies' => 'id,name'}} - assert_response :success - assert json_response['data'].is_a?(Hash) - assert json_response['data']['attributes'].key?('transactionDate') - assert_equal 2, json_response['included'].size + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + assert_cacheable_get :show, params: { id: 1, include: 'isoCurrency,employee', 'fields' => { 'expenseEntries' => 'transactionDate', + 'isoCurrencies' => 'id,name' } } + assert_response :success + assert json_response['data'].is_a?(Hash) + assert json_response['data']['attributes'].key?('transactionDate') + assert_equal 2, json_response['included'].size + end end def test_create_expense_entries_underscored set_content_type_header! - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :underscored_key - post :create, params: - { - data: { - type: 'expense_entries', - attributes: { - transaction_date: '2014/04/15', - cost: 50.58 + with_jsonapi_config_changes do + + JSONAPI.configuration.json_key_format = :underscored_key + + post :create, params: + { + data: { + type: 'expense_entries', + attributes: { + transaction_date: '2014/04/15', + cost: 50.58 + }, + relationships: { + employee: { data: { type: 'employees', id: '1003' } }, + iso_currency: { data: { type: 'iso_currencies', id: 'USD' } } + } }, - relationships: { - employee: {data: {type: 'employees', id: '1003'}}, - iso_currency: {data: {type: 'iso_currencies', id: 'USD'}} - } - }, - include: 'iso_currency,employee', - fields: {expense_entries: 'id,transaction_date,iso_currency,cost,employee'} - } + include: 'iso_currency,employee', + fields: { expense_entries: 'id,transaction_date,iso_currency,cost,employee' } + } - assert_response :created - assert json_response['data'].is_a?(Hash) - assert_equal '1003', json_response['data']['relationships']['employee']['data']['id'] - assert_equal 'USD', json_response['data']['relationships']['iso_currency']['data']['id'] - assert_equal '50.58', json_response['data']['attributes']['cost'] + assert_response :created + assert json_response['data'].is_a?(Hash) + assert_equal '1003', json_response['data']['relationships']['employee']['data']['id'] + assert_equal 'USD', json_response['data']['relationships']['iso_currency']['data']['id'] + assert_equal '50.58', json_response['data']['attributes']['cost'] - delete :destroy, params: {id: json_response['data']['id']} - assert_response :no_content - ensure - JSONAPI.configuration = original_config + delete :destroy, params: { id: json_response['data']['id'] } + assert_response :no_content + end end def test_create_expense_entries_camelized_key set_content_type_header! - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :camelized_key - post :create, params: - { - data: { - type: 'expense_entries', - attributes: { - transactionDate: '2014/04/15', - cost: 50.58 + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + post :create, params: + { + data: { + type: 'expense_entries', + attributes: { + transactionDate: '2014/04/15', + cost: 50.58 + }, + relationships: { + employee: { data: { type: 'employees', id: '1003' } }, + isoCurrency: { data: { type: 'iso_currencies', id: 'USD' } } + } }, - relationships: { - employee: {data: {type: 'employees', id: '1003'}}, - isoCurrency: {data: {type: 'iso_currencies', id: 'USD'}} - } - }, - include: 'isoCurrency,employee', - fields: {expenseEntries: 'id,transactionDate,isoCurrency,cost,employee'} - } + include: 'isoCurrency,employee', + fields: { expenseEntries: 'id,transactionDate,isoCurrency,cost,employee' } + } - assert_response :created - assert json_response['data'].is_a?(Hash) - assert_equal '1003', json_response['data']['relationships']['employee']['data']['id'] - assert_equal 'USD', json_response['data']['relationships']['isoCurrency']['data']['id'] - assert_equal '50.58', json_response['data']['attributes']['cost'] + assert_response :created + assert json_response['data'].is_a?(Hash) + assert_equal '1003', json_response['data']['relationships']['employee']['data']['id'] + assert_equal 'USD', json_response['data']['relationships']['isoCurrency']['data']['id'] + assert_equal '50.58', json_response['data']['attributes']['cost'] - delete :destroy, params: {id: json_response['data']['id']} - assert_response :no_content - ensure - JSONAPI.configuration = original_config + delete :destroy, params: { id: json_response['data']['id'] } + assert_response :no_content + end end def test_create_expense_entries_dasherized_key set_content_type_header! - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :dasherized_key - post :create, params: - { - data: { - type: 'expense_entries', - attributes: { - 'transaction-date' => '2014/04/15', - cost: 50.58 + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key + + post :create, params: + { + data: { + type: 'expense_entries', + attributes: { + 'transaction-date' => '2014/04/15', + cost: 50.58 + }, + relationships: { + employee: { data: { type: 'employees', id: '1003' } }, + 'iso-currency' => { data: { type: 'iso_currencies', id: 'USD' } } + } }, - relationships: { - employee: {data: {type: 'employees', id: '1003'}}, - 'iso-currency' => {data: {type: 'iso_currencies', id: 'USD'}} - } - }, - include: 'iso-currency,employee', - fields: {'expense-entries' => 'id,transaction-date,iso-currency,cost,employee'} - } + include: 'iso-currency,employee', + fields: { 'expense-entries' => 'id,transaction-date,iso-currency,cost,employee' } + } - assert_response :created - assert json_response['data'].is_a?(Hash) - assert_equal '1003', json_response['data']['relationships']['employee']['data']['id'] - assert_equal 'USD', json_response['data']['relationships']['iso-currency']['data']['id'] - assert_equal '50.58', json_response['data']['attributes']['cost'] + assert_response :created + assert json_response['data'].is_a?(Hash) + assert_equal '1003', json_response['data']['relationships']['employee']['data']['id'] + assert_equal 'USD', json_response['data']['relationships']['iso-currency']['data']['id'] + assert_equal '50.58', json_response['data']['attributes']['cost'] - delete :destroy, params: {id: json_response['data']['id']} - assert_response :no_content - ensure - JSONAPI.configuration = original_config + delete :destroy, params: { id: json_response['data']['id'] } + assert_response :no_content + end end end class IsoCurrenciesControllerTest < ActionController::TestCase - def after_teardown - JSONAPI.configuration.json_key_format = :camelized_key - end - def test_currencies_show - assert_cacheable_get :show, params: {id: 'USD'} + assert_cacheable_get :show, params: { id: 'USD' } assert_response :success assert json_response['data'].is_a?(Hash) end def test_create_currencies_client_generated_id set_content_type_header! - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :underscored_route + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :underscored_route - post :create, params: - { - data: { - type: 'iso_currencies', - id: 'BTC', - attributes: { - name: 'Bit Coin', - 'country_name' => 'global', - 'minor_unit' => 'satoshi' + post :create, params: + { + data: { + type: 'iso_currencies', + id: 'BTC', + attributes: { + name: 'Bit Coin', + 'country_name' => 'global', + 'minor_unit' => 'satoshi' + } } } - } - assert_response :created - assert_equal 'BTC', json_response['data']['id'] - assert_equal 'Bit Coin', json_response['data']['attributes']['name'] - assert_equal 'global', json_response['data']['attributes']['country_name'] - assert_equal 'satoshi', json_response['data']['attributes']['minor_unit'] + assert_response :created + assert_equal 'BTC', json_response['data']['id'] + assert_equal 'Bit Coin', json_response['data']['attributes']['name'] + assert_equal 'global', json_response['data']['attributes']['country_name'] + assert_equal 'satoshi', json_response['data']['attributes']['minor_unit'] - delete :destroy, params: {id: json_response['data']['id']} - assert_response :no_content - ensure - JSONAPI.configuration = original_config + delete :destroy, params: { id: json_response['data']['id'] } + assert_response :no_content + end end def test_currencies_primary_key_sort - assert_cacheable_get :index, params: {sort: 'id'} + assert_cacheable_get :index, params: { sort: 'id' } assert_response :success assert_equal 3, json_response['data'].size assert_equal 'CAD', json_response['data'][0]['id'] @@ -2474,219 +2475,220 @@ def test_currencies_primary_key_sort end def test_currencies_code_sort - assert_cacheable_get :index, params: {sort: 'code'} + assert_cacheable_get :index, params: { sort: 'code' } assert_response :bad_request end def test_currencies_json_key_underscored_sort - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :underscored_key - assert_cacheable_get :index, params: {sort: 'country_name'} - assert_response :success - assert_equal 3, json_response['data'].size - assert_equal 'Canada', json_response['data'][0]['attributes']['country_name'] - assert_equal 'Euro Member Countries', json_response['data'][1]['attributes']['country_name'] - assert_equal 'United States', json_response['data'][2]['attributes']['country_name'] + with_jsonapi_config_changes do - # reverse sort - assert_cacheable_get :index, params: {sort: '-country_name'} - assert_response :success - assert_equal 3, json_response['data'].size - assert_equal 'United States', json_response['data'][0]['attributes']['country_name'] - assert_equal 'Euro Member Countries', json_response['data'][1]['attributes']['country_name'] - assert_equal 'Canada', json_response['data'][2]['attributes']['country_name'] - ensure - JSONAPI.configuration = original_config + JSONAPI.configuration.json_key_format = :underscored_key + assert_cacheable_get :index, params: { sort: 'country_name' } + assert_response :success + assert_equal 3, json_response['data'].size + assert_equal 'Canada', json_response['data'][0]['attributes']['country_name'] + assert_equal 'Euro Member Countries', json_response['data'][1]['attributes']['country_name'] + assert_equal 'United States', json_response['data'][2]['attributes']['country_name'] + + # reverse sort + assert_cacheable_get :index, params: { sort: '-country_name' } + assert_response :success + assert_equal 3, json_response['data'].size + assert_equal 'United States', json_response['data'][0]['attributes']['country_name'] + assert_equal 'Euro Member Countries', json_response['data'][1]['attributes']['country_name'] + assert_equal 'Canada', json_response['data'][2]['attributes']['country_name'] + end end def test_currencies_json_key_dasherized_sort - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :dasherized_key - assert_cacheable_get :index, params: {sort: 'country-name'} - assert_response :success - assert_equal 3, json_response['data'].size - assert_equal 'Canada', json_response['data'][0]['attributes']['country-name'] - assert_equal 'Euro Member Countries', json_response['data'][1]['attributes']['country-name'] - assert_equal 'United States', json_response['data'][2]['attributes']['country-name'] + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key + assert_cacheable_get :index, params: { sort: 'country-name' } + assert_response :success + assert_equal 3, json_response['data'].size + assert_equal 'Canada', json_response['data'][0]['attributes']['country-name'] + assert_equal 'Euro Member Countries', json_response['data'][1]['attributes']['country-name'] + assert_equal 'United States', json_response['data'][2]['attributes']['country-name'] - # reverse sort - assert_cacheable_get :index, params: {sort: '-country-name'} - assert_response :success - assert_equal 3, json_response['data'].size - assert_equal 'United States', json_response['data'][0]['attributes']['country-name'] - assert_equal 'Euro Member Countries', json_response['data'][1]['attributes']['country-name'] - assert_equal 'Canada', json_response['data'][2]['attributes']['country-name'] - ensure - JSONAPI.configuration = original_config + # reverse sort + assert_cacheable_get :index, params: { sort: '-country-name' } + assert_response :success + assert_equal 3, json_response['data'].size + assert_equal 'United States', json_response['data'][0]['attributes']['country-name'] + assert_equal 'Euro Member Countries', json_response['data'][1]['attributes']['country-name'] + assert_equal 'Canada', json_response['data'][2]['attributes']['country-name'] + end end def test_currencies_json_key_custom_json_key_sort - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :upper_camelized_key - assert_cacheable_get :index, params: {sort: 'CountryName'} - assert_response :success - assert_equal 3, json_response['data'].size - assert_equal 'Canada', json_response['data'][0]['attributes']['CountryName'] - assert_equal 'Euro Member Countries', json_response['data'][1]['attributes']['CountryName'] - assert_equal 'United States', json_response['data'][2]['attributes']['CountryName'] + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :upper_camelized_key + assert_cacheable_get :index, params: { sort: 'CountryName' } + assert_response :success + assert_equal 3, json_response['data'].size + assert_equal 'Canada', json_response['data'][0]['attributes']['CountryName'] + assert_equal 'Euro Member Countries', json_response['data'][1]['attributes']['CountryName'] + assert_equal 'United States', json_response['data'][2]['attributes']['CountryName'] - # reverse sort - assert_cacheable_get :index, params: {sort: '-CountryName'} - assert_response :success - assert_equal 3, json_response['data'].size - assert_equal 'United States', json_response['data'][0]['attributes']['CountryName'] - assert_equal 'Euro Member Countries', json_response['data'][1]['attributes']['CountryName'] - assert_equal 'Canada', json_response['data'][2]['attributes']['CountryName'] - ensure - JSONAPI.configuration = original_config + # reverse sort + assert_cacheable_get :index, params: { sort: '-CountryName' } + assert_response :success + assert_equal 3, json_response['data'].size + assert_equal 'United States', json_response['data'][0]['attributes']['CountryName'] + assert_equal 'Euro Member Countries', json_response['data'][1]['attributes']['CountryName'] + assert_equal 'Canada', json_response['data'][2]['attributes']['CountryName'] + end end def test_currencies_json_key_underscored_filter - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :underscored_key - assert_cacheable_get :index, params: {filter: {country_name: 'Canada'}} - assert_response :success - assert_equal 1, json_response['data'].size - assert_equal 'Canada', json_response['data'][0]['attributes']['country_name'] - ensure - JSONAPI.configuration = original_config + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :underscored_key + assert_cacheable_get :index, params: { filter: { country_name: 'Canada' } } + assert_response :success + assert_equal 1, json_response['data'].size + assert_equal 'Canada', json_response['data'][0]['attributes']['country_name'] + end end def test_currencies_json_key_camelized_key_filter - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :camelized_key - assert_cacheable_get :index, params: {filter: {'countryName' => 'Canada'}} - assert_response :success - assert_equal 1, json_response['data'].size - assert_equal 'Canada', json_response['data'][0]['attributes']['countryName'] - ensure - JSONAPI.configuration = original_config + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + assert_cacheable_get :index, params: { filter: { 'countryName' => 'Canada' } } + assert_response :success + assert_equal 1, json_response['data'].size + assert_equal 'Canada', json_response['data'][0]['attributes']['countryName'] + end end def test_currencies_json_key_custom_json_key_filter - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :upper_camelized_key - assert_cacheable_get :index, params: {filter: {'CountryName' => 'Canada'}} - assert_response :success - assert_equal 1, json_response['data'].size - assert_equal 'Canada', json_response['data'][0]['attributes']['CountryName'] - ensure - JSONAPI.configuration = original_config + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :upper_camelized_key + assert_cacheable_get :index, params: { filter: { 'CountryName' => 'Canada' } } + assert_response :success + assert_equal 1, json_response['data'].size + assert_equal 'Canada', json_response['data'][0]['attributes']['CountryName'] + end end end class PeopleControllerTest < ActionController::TestCase - def setup - JSONAPI.configuration.json_key_format = :camelized_key - end - def test_create_validations - set_content_type_header! - post :create, params: - { - data: { - type: 'people', - attributes: { - name: 'Steve Jobs', - email: 'sj@email.zzz', - dateJoined: DateTime.parse('2014-1-30 4:20:00 UTC +00:00') + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + set_content_type_header! + post :create, params: + { + data: { + type: 'people', + attributes: { + name: 'Steve Jobs', + email: 'sj@email.zzz', + dateJoined: DateTime.parse('2014-1-30 4:20:00 UTC +00:00') + } } } - } - assert_response :success + assert_response :success + end end def test_update_link_with_dasherized_type - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :dasherized_key - set_content_type_header! - put :update, params: - { - id: 1003, - data: { - id: '1003', - type: 'people', - relationships: { - 'hair-cut' => { - data: { - type: 'hair-cuts', - id: '1' + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key + set_content_type_header! + put :update, params: + { + id: 1003, + data: { + id: '1003', + type: 'people', + relationships: { + 'hair-cut' => { + data: { + type: 'hair-cuts', + id: '1' + } } } } } - } - assert_response :success - ensure - JSONAPI.configuration = original_config + assert_response :success + end end def test_create_validations_missing_attribute - set_content_type_header! - post :create, params: - { - data: { - type: 'people', - attributes: { - email: 'sj@email.zzz' + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + set_content_type_header! + post :create, params: + { + data: { + type: 'people', + attributes: { + email: 'sj@email.zzz' + } } } - } - assert_response :unprocessable_entity - assert_equal 2, json_response['errors'].size - assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][0]['code'] - assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][1]['code'] - assert_match /dateJoined - can't be blank/, response.body - assert_match /name - can't be blank/, response.body + assert_response :unprocessable_entity + assert_equal 2, json_response['errors'].size + assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][0]['code'] + assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][1]['code'] + assert_match /dateJoined - can't be blank/, response.body + assert_match /name - can't be blank/, response.body + end end def test_update_validations_missing_attribute - set_content_type_header! - put :update, params: - { - id: 1003, - data: { - id: '1003', - type: 'people', - attributes: { - name: '' - } - } - } + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key - assert_response :unprocessable_entity - assert_equal 1, json_response['errors'].size - assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][0]['code'] - assert_match /name - can't be blank/, response.body + set_content_type_header! + put :update, params: + { + id: 1003, + data: { + id: '1003', + type: 'people', + attributes: { + name: '' + } + } + } + + assert_response :unprocessable_entity + assert_equal 1, json_response['errors'].size + assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][0]['code'] + assert_match /name - can't be blank/, response.body + end end def test_delete_locked initial_count = Person.count - delete :destroy, params: {id: '1003'} + delete :destroy, params: { id: '1003' } assert_response :locked assert_equal initial_count, Person.count end def test_invalid_filter_value - assert_cacheable_get :index, params: {filter: {name: 'L'}} + assert_cacheable_get :index, params: { filter: { name: 'L' } } assert_response :bad_request end def test_invalid_filter_value_for_index_related_resources assert_cacheable_get :index_related_resources, params: { - hair_cut_id: 1, - relationship: 'people', - source: 'hair_cuts', - filter: {name: 'L'} - } + hair_cut_id: 1, + relationship: 'people', + source: 'hair_cuts', + filter: { name: 'L' } + } assert_response :bad_request end def test_valid_filter_value - assert_cacheable_get :index, params: {filter: {name: 'Joe Author'}} + assert_cacheable_get :index, params: { filter: { name: 'Joe Author' } } assert_response :success assert_equal json_response['data'].size, 1 assert_equal '1001', json_response['data'][0]['id'] @@ -2694,84 +2696,82 @@ def test_valid_filter_value end def test_show_related_resource_no_namespace - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :dasherized_key - JSONAPI.configuration.route_format = :underscored_key - assert_cacheable_get :show_related_resource, params: {post_id: '2', relationship: 'author', source:'posts'} - assert_response :success + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key + JSONAPI.configuration.route_format = :underscored_key + assert_cacheable_get :show_related_resource, params: { post_id: '2', relationship: 'author', source: 'posts' } + assert_response :success - assert_hash_equals( - { - "data" => { - "id" => "1001", - "type" => "people", - "links" => { + assert_hash_equals( + { + "data" => { + "id" => "1001", + "type" => "people", + "links" => { "self" => "http://test.host/people/1001" - }, - "attributes" => { - "name" => "Joe Author", - "email" => "joe@xyz.fake", - "date-joined" => "2013-08-07 16:25:00 -0400" - }, - "relationships" => { - "comments" => { - "links" => { - "self" => "http://test.host/people/1001/relationships/comments", - "related" => "http://test.host/people/1001/comments" - } - }, - "posts" => { - "links" => { - "self" => "http://test.host/people/1001/relationships/posts", - "related" => "http://test.host/people/1001/posts" - } - }, - "preferences" => { - "links" => { - "self" => "http://test.host/people/1001/relationships/preferences", - "related" => "http://test.host/people/1001/preferences" - } }, - "vehicles" => { - "links" => { - "self" => "http://test.host/people/1001/relationships/vehicles", - "related" => "http://test.host/people/1001/vehicles" - } + "attributes" => { + "name" => "Joe Author", + "email" => "joe@xyz.fake", + "date-joined" => "2013-08-07 16:25:00 -0400" }, - "hair-cut" => { + "relationships" => { + "comments" => { "links" => { - "self" => "http://test.host/people/1001/relationships/hair_cut", - "related" => "http://test.host/people/1001/hair_cut" + "self" => "http://test.host/people/1001/relationships/comments", + "related" => "http://test.host/people/1001/comments" } - }, - "expense-entries" => { + }, + "posts" => { "links" => { - "self" => "http://test.host/people/1001/relationships/expense_entries", - "related" => "http://test.host/people/1001/expense_entries" + "self" => "http://test.host/people/1001/relationships/posts", + "related" => "http://test.host/people/1001/posts" } + }, + "preferences" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/preferences", + "related" => "http://test.host/people/1001/preferences" + } + }, + "vehicles" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/vehicles", + "related" => "http://test.host/people/1001/vehicles" + } + }, + "hair-cut" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/hair_cut", + "related" => "http://test.host/people/1001/hair_cut" + } + }, + "expense-entries" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/expense_entries", + "related" => "http://test.host/people/1001/expense_entries" + } + } } } - } - }, - json_response - ) - ensure - JSONAPI.configuration = original_config + }, + json_response + ) + end end def test_show_related_resource_includes - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :dasherized_key - JSONAPI.configuration.route_format = :underscored_key - assert_cacheable_get :show_related_resource, params: {post_id: '2', relationship: 'author', source:'posts', include: 'posts'} - assert_response :success - assert_equal 'posts', json_response['included'][0]['type'] - ensure - JSONAPI.configuration = original_config + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key + JSONAPI.configuration.route_format = :underscored_key + assert_cacheable_get :show_related_resource, params: { post_id: '2', relationship: 'author', source: 'posts', include: 'posts' } + assert_response :success + assert_equal 'posts', json_response['included'][0]['type'] + end end def test_show_related_resource_nil - assert_cacheable_get :show_related_resource, params: {post_id: '17', relationship: 'author', source:'posts'} + assert_cacheable_get :show_related_resource, params: { post_id: '17', relationship: 'author', source: 'posts' } assert_response :success assert_hash_equals json_response, { @@ -2784,34 +2784,34 @@ def test_show_related_resource_nil class BooksControllerTest < ActionController::TestCase def test_books_include_correct_type $test_user = Person.find(1001) - assert_cacheable_get :index, params: {filter: {id: '1'}, include: 'authors'} + assert_cacheable_get :index, params: { filter: { id: '1' }, include: 'authors' } assert_response :success assert_equal 'authors', json_response['included'][0]['type'] end def test_destroy_relationship_has_and_belongs_to_many - JSONAPI.configuration.use_relationship_reflection = false + with_jsonapi_config_changes do + JSONAPI.configuration.use_relationship_reflection = false - assert_equal 2, Book.find(2).authors.count + assert_equal 2, Book.find(2).authors.count - delete :destroy_relationship, params: {book_id: 2, relationship: 'authors', data: [{type: 'authors', id: '1001'}]} - assert_response :no_content - assert_equal 1, Book.find(2).authors.count - ensure - JSONAPI.configuration.use_relationship_reflection = false + delete :destroy_relationship, params: { book_id: 2, relationship: 'authors', data: [{ type: 'authors', id: '1001' }] } + assert_response :no_content + assert_equal 1, Book.find(2).authors.count + end end def test_destroy_relationship_has_and_belongs_to_many_reflect - JSONAPI.configuration.use_relationship_reflection = true + with_jsonapi_config_changes do + JSONAPI.configuration.use_relationship_reflection = true - assert_equal 2, Book.find(2).authors.count + assert_equal 2, Book.find(2).authors.count - delete :destroy_relationship, params: {book_id: 2, relationship: 'authors', data: [{type: 'authors', id: '1001'}]} - assert_response :no_content - assert_equal 1, Book.find(2).authors.count + delete :destroy_relationship, params: { book_id: 2, relationship: 'authors', data: [{ type: 'authors', id: '1001' }] } + assert_response :no_content + assert_equal 1, Book.find(2).authors.count - ensure - JSONAPI.configuration.use_relationship_reflection = false + end end def test_index_with_caching_enabled_uses_context @@ -2823,31 +2823,31 @@ def test_index_with_caching_enabled_uses_context class Api::V5::PostsControllerTest < ActionController::TestCase def test_show_post_no_relationship_routes_exludes_relationships - assert_cacheable_get :show, params: {id: '1'} + assert_cacheable_get :show, params: { id: '1' } assert_response :success assert_nil json_response['data']['relationships'] end def test_exclude_resource_links - assert_cacheable_get :show, params: {id: '1'} + assert_cacheable_get :show, params: { id: '1' } assert_response :success assert_nil json_response['data']['relationships'] assert_equal 1, json_response['data']['links'].length Api::V5::PostResource.exclude_links :default - assert_cacheable_get :show, params: {id: '1'} + assert_cacheable_get :show, params: { id: '1' } assert_response :success assert_nil json_response['data']['relationships'] assert_nil json_response['data']['links'] Api::V5::PostResource.exclude_links [:self] - assert_cacheable_get :show, params: {id: '1'} + assert_cacheable_get :show, params: { id: '1' } assert_response :success assert_nil json_response['data']['relationships'] assert_nil json_response['data']['links'] Api::V5::PostResource.exclude_links :none - assert_cacheable_get :show, params: {id: '1'} + assert_cacheable_get :show, params: { id: '1' } assert_response :success assert_nil json_response['data']['relationships'] assert_equal 1, json_response['data']['links'].length @@ -2856,7 +2856,7 @@ def test_exclude_resource_links end def test_show_post_no_relationship_route_include - get :show, params: {id: '1', include: 'author'} + assert_cacheable_get :show, params: { id: '1', include: 'author' } assert_response :success assert_equal '1001', json_response['data']['relationships']['author']['data']['id'] assert_nil json_response['data']['relationships']['tags'] @@ -2868,7 +2868,7 @@ def test_show_post_no_relationship_route_include class Api::V5::AuthorsControllerTest < ActionController::TestCase def test_get_person_as_author - assert_cacheable_get :index, params: {filter: {id: '1001'}} + assert_cacheable_get :index, params: { filter: { id: '1001' } } assert_response :success assert_equal 1, json_response['data'].size assert_equal '1001', json_response['data'][0]['id'] @@ -2878,7 +2878,7 @@ def test_get_person_as_author end def test_show_person_as_author - assert_cacheable_get :show, params: {id: '1001'} + assert_cacheable_get :show, params: { id: '1001' } assert_response :success assert_equal '1001', json_response['data']['id'] assert_equal 'authors', json_response['data']['type'] @@ -2887,7 +2887,7 @@ def test_show_person_as_author end def test_get_person_as_author_by_name_filter - assert_cacheable_get :index, params: {filter: {name: 'thor'}} + assert_cacheable_get :index, params: { filter: { name: 'thor' } } assert_response :success assert_equal 3, json_response['data'].size assert_equal '1001', json_response['data'][0]['id'] @@ -2895,8 +2895,6 @@ def test_get_person_as_author_by_name_filter end def test_meta_serializer_options - JSONAPI.configuration.json_key_format = :camelized_key - Api::V5::AuthorResource.class_eval do def meta(options) { @@ -2908,28 +2906,29 @@ def meta(options) end end - assert_cacheable_get :show, params: {id: '1001'} - assert_response :success - assert_equal '1001', json_response['data']['id'] - assert_equal 'Hardcoded value', json_response['data']['meta']['fixed'] - assert_equal 'authors: http://test.host/api/v5/authors/1001', json_response['data']['meta']['computed'] - assert_equal 'bar', json_response['data']['meta']['computed_foo'] - assert_equal 'test value', json_response['data']['meta']['testKey'] + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + assert_cacheable_get :show, params: { id: '1001' } + assert_response :success + assert_equal '1001', json_response['data']['id'] + assert_equal 'Hardcoded value', json_response['data']['meta']['fixed'] + assert_equal 'authors: http://test.host/api/v5/authors/1001', json_response['data']['meta']['computed'] + assert_equal 'bar', json_response['data']['meta']['computed_foo'] + assert_equal 'test value', json_response['data']['meta']['testKey'] + end ensure - JSONAPI.configuration.json_key_format = :dasherized_key Api::V5::AuthorResource.class_eval do def meta(options) # :nocov: - { } + {} # :nocov: end end end def test_meta_serializer_hash_data - JSONAPI.configuration.json_key_format = :camelized_key - Api::V5::AuthorResource.class_eval do def meta(options) { @@ -2943,20 +2942,21 @@ def meta(options) end end - assert_cacheable_get :show, params: {id: '1001'} - assert_response :success - assert_equal '1001', json_response['data']['id'] - assert_equal 'Hardcoded value', json_response['data']['meta']['custom_hash']['fixed'] - assert_equal 'authors: http://test.host/api/v5/authors/1001', json_response['data']['meta']['custom_hash']['computed'] - assert_equal 'bar', json_response['data']['meta']['custom_hash']['computed_foo'] - assert_equal 'test value', json_response['data']['meta']['custom_hash']['testKey'] - + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + assert_cacheable_get :show, params: { id: '1001' } + assert_response :success + assert_equal '1001', json_response['data']['id'] + assert_equal 'Hardcoded value', json_response['data']['meta']['custom_hash']['fixed'] + assert_equal 'authors: http://test.host/api/v5/authors/1001', json_response['data']['meta']['custom_hash']['computed'] + assert_equal 'bar', json_response['data']['meta']['custom_hash']['computed_foo'] + assert_equal 'test value', json_response['data']['meta']['custom_hash']['testKey'] + end ensure - JSONAPI.configuration.json_key_format = :dasherized_key Api::V5::AuthorResource.class_eval do def meta(options) # :nocov: - { } + {} # :nocov: end end @@ -2974,7 +2974,7 @@ def test_poro_index end def test_poro_show - get :show, params: {id: '0'} + get :show, params: { id: '0' } assert_response :success assert json_response['data'].is_a?(Hash) assert_equal '0', json_response['data']['id'] @@ -2982,7 +2982,7 @@ def test_poro_show end def test_poro_show_multiple - assert_cacheable_get :show, params: {id: '0,2'} + assert_cacheable_get :show, params: { id: '0,2' } assert_response :bad_request assert_match /0,2 is not a valid value for id/, response.body @@ -3056,7 +3056,7 @@ def test_poro_create_update def test_poro_delete initial_count = $breed_data.breeds.keys.count - delete :destroy, params: {id: '3'} + delete :destroy, params: { id: '3' } assert_response :no_content assert_equal initial_count - 1, $breed_data.breeds.keys.count end @@ -3089,13 +3089,13 @@ def test_update_singleton_resource_without_id class Api::V1::PostsControllerTest < ActionController::TestCase def test_show_post_namespaced - assert_cacheable_get :show, params: {id: '1'} + assert_cacheable_get :show, params: { id: '1' } assert_response :success assert_equal 'http://test.host/api/v1/posts/1/relationships/writer', json_response['data']['relationships']['writer']['links']['self'] end def test_show_post_namespaced_include - assert_cacheable_get :show, params: {id: '1', include: 'writer'} + assert_cacheable_get :show, params: { id: '1', include: 'writer' } assert_response :success assert_equal '1001', json_response['data']['relationships']['writer']['data']['id'] assert_nil json_response['data']['relationships']['tags'] @@ -3105,13 +3105,13 @@ def test_show_post_namespaced_include end def test_index_filter_on_relationship_namespaced - assert_cacheable_get :index, params: {filter: {writer: '1001'}} + assert_cacheable_get :index, params: { filter: { writer: '1001' } } assert_response :success assert_equal 3, json_response['data'].size end def test_sorting_desc_namespaced - assert_cacheable_get :index, params: {sort: '-title'} + assert_cacheable_get :index, params: { sort: '-title' } assert_response :success assert_equal "Update This Later - Multiple", json_response['data'][0]['attributes']['title'] @@ -3128,7 +3128,7 @@ def test_create_simple_namespaced body: 'JSONAPIResources is the greatest thing since unsliced bread now that it has namespaced resources.' }, relationships: { - writer: { data: {type: 'writers', id: '1003'}} + writer: { data: { type: 'writers', id: '1003' } } } } } @@ -3144,65 +3144,62 @@ def test_create_simple_namespaced class FactsControllerTest < ActionController::TestCase def test_type_formatting - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :camelized_key - assert_cacheable_get :show, params: {id: '1'} - assert_response :success - assert json_response['data'].is_a?(Hash) - assert_equal 'Jane Author', json_response['data']['attributes']['spouseName'] - assert_equal 'First man to run across Antartica.', json_response['data']['attributes']['bio'] - assert_equal (23.89/45.6).round(5), json_response['data']['attributes']['qualityRating'].round(5) - assert_equal '47000.56', json_response['data']['attributes']['salary'] - assert_equal '2013-08-07T20:25:00.000Z', json_response['data']['attributes']['dateTimeJoined'] - assert_equal '1965-06-30', json_response['data']['attributes']['birthday'] - assert_equal '2000-01-01T20:00:00.000Z', json_response['data']['attributes']['bedtime'] - assert_equal 'abc', json_response['data']['attributes']['photo'] - assert_equal false, json_response['data']['attributes']['cool'] - ensure - JSONAPI.configuration = original_config + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + assert_cacheable_get :show, params: { id: '1' } + assert_response :success + assert json_response['data'].is_a?(Hash) + assert_equal 'Jane Author', json_response['data']['attributes']['spouseName'] + assert_equal 'First man to run across Antartica.', json_response['data']['attributes']['bio'] + assert_equal (23.89 / 45.6).round(5), json_response['data']['attributes']['qualityRating'].round(5) + assert_equal '47000.56', json_response['data']['attributes']['salary'] + assert_equal '2013-08-07T20:25:00.000Z', json_response['data']['attributes']['dateTimeJoined'] + assert_equal '1965-06-30', json_response['data']['attributes']['birthday'] + assert_equal '2000-01-01T20:00:00.000Z', json_response['data']['attributes']['bedtime'] + assert_equal 'abc', json_response['data']['attributes']['photo'] + assert_equal false, json_response['data']['attributes']['cool'] + end end def test_create_with_invalid_data - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :dasherized_key - set_content_type_header! - post :create, params: - { - data: { - type: 'facts', - attributes: { - bio: '', - :"quality-rating" => '', - :"spouse-name" => '', - salary: 100000, - :"date-time-joined" => '', - birthday: '', - bedtime: '', - photo: 'abc', - cool: false - }, - relationships: { + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key + set_content_type_header! + post :create, params: + { + data: { + type: 'facts', + attributes: { + bio: '', + :"quality-rating" => '', + :"spouse-name" => '', + salary: 100000, + :"date-time-joined" => '', + birthday: '', + bedtime: '', + photo: 'abc', + cool: false + }, + relationships: { + } } } - } - assert_response :unprocessable_entity + assert_response :unprocessable_entity - assert_equal "/data/attributes/spouse-name", json_response['errors'][0]['source']['pointer'] - assert_equal "can't be blank", json_response['errors'][0]['title'] - assert_equal "spouse-name - can't be blank", json_response['errors'][0]['detail'] + assert_equal "/data/attributes/spouse-name", json_response['errors'][0]['source']['pointer'] + assert_equal "can't be blank", json_response['errors'][0]['title'] + assert_equal "spouse-name - can't be blank", json_response['errors'][0]['detail'] - assert_equal "/data/attributes/bio", json_response['errors'][1]['source']['pointer'] - assert_equal "can't be blank", json_response['errors'][1]['title'] - assert_equal "bio - can't be blank", json_response['errors'][1]['detail'] - ensure - JSONAPI.configuration = original_config + assert_equal "/data/attributes/bio", json_response['errors'][1]['source']['pointer'] + assert_equal "can't be blank", json_response['errors'][1]['title'] + assert_equal "bio - can't be blank", json_response['errors'][1]['detail'] + end end end class Api::V2::BooksControllerTest < ActionController::TestCase def setup - JSONAPI.configuration.json_key_format = :dasherized_key $test_user = Person.find(1001) end @@ -3220,97 +3217,119 @@ def test_books_offset_pagination_no_params end def test_books_record_count_in_meta - Api::V2::BookResource.paginator :offset - JSONAPI.configuration.top_level_meta_include_record_count = true - assert_cacheable_get :index, params: {include: 'book-comments'} - JSONAPI.configuration.top_level_meta_include_record_count = false + with_jsonapi_config_changes do + Api::V2::BookResource.paginator :offset + JSONAPI.configuration.json_key_format = :dasherized_key + JSONAPI.configuration.top_level_meta_include_record_count = true + assert_cacheable_get :index, params: { include: 'book-comments' } + JSONAPI.configuration.top_level_meta_include_record_count = false - assert_response :success - assert_equal 901, json_response['meta']['record-count'] - assert_equal 10, json_response['data'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + assert_response :success + assert_equal 901, json_response['meta']['record-count'] + assert_equal 10, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + end end def test_books_page_count_in_meta - Api::V2::BookResource.paginator :paged - JSONAPI.configuration.top_level_meta_include_page_count = true - assert_cacheable_get :index, params: {include: 'book-comments'} - JSONAPI.configuration.top_level_meta_include_page_count = false + with_jsonapi_config_changes do + Api::V2::BookResource.paginator :paged + JSONAPI.configuration.json_key_format = :dasherized_key + JSONAPI.configuration.top_level_meta_include_page_count = true + assert_cacheable_get :index, params: { include: 'book-comments' } + JSONAPI.configuration.top_level_meta_include_page_count = false - assert_response :success - assert_equal 91, json_response['meta']['page-count'] - assert_equal 10, json_response['data'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + assert_response :success + assert_equal 91, json_response['meta']['page-count'] + assert_equal 10, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + end end def test_books_no_page_count_in_meta_with_none_paginator - Api::V2::BookResource.paginator :none - JSONAPI.configuration.top_level_meta_include_page_count = true - assert_cacheable_get :index, params: {include: 'book-comments'} - JSONAPI.configuration.top_level_meta_include_page_count = false + with_jsonapi_config_changes do + Api::V2::BookResource.paginator :none + JSONAPI.configuration.json_key_format = :dasherized_key + JSONAPI.configuration.top_level_meta_include_page_count = true + assert_cacheable_get :index, params: { include: 'book-comments' } + JSONAPI.configuration.top_level_meta_include_page_count = false - assert_response :success - assert_nil json_response['meta']['page-count'] - assert_equal 901, json_response['data'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + assert_response :success + assert_nil json_response['meta']['page-count'] + assert_equal 901, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + end end def test_books_record_count_in_meta_custom_name - Api::V2::BookResource.paginator :offset - JSONAPI.configuration.top_level_meta_include_record_count = true - JSONAPI.configuration.top_level_meta_record_count_key = 'total_records' + with_jsonapi_config_changes do + Api::V2::BookResource.paginator :offset + JSONAPI.configuration.json_key_format = :dasherized_key + JSONAPI.configuration.top_level_meta_include_record_count = true + JSONAPI.configuration.top_level_meta_record_count_key = 'total_records' - assert_cacheable_get :index, params: {include: 'book-comments'} - JSONAPI.configuration.top_level_meta_include_record_count = false - JSONAPI.configuration.top_level_meta_record_count_key = :record_count + assert_cacheable_get :index, params: { include: 'book-comments' } + JSONAPI.configuration.top_level_meta_include_record_count = false + JSONAPI.configuration.top_level_meta_record_count_key = :record_count - assert_response :success - assert_equal 901, json_response['meta']['total-records'] - assert_equal 10, json_response['data'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + assert_response :success + assert_equal 901, json_response['meta']['total-records'] + assert_equal 10, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + end end def test_books_page_count_in_meta_custom_name - Api::V2::BookResource.paginator :paged - JSONAPI.configuration.top_level_meta_include_page_count = true - JSONAPI.configuration.top_level_meta_page_count_key = 'total_pages' + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key - assert_cacheable_get :index, params: {include: 'book-comments'} - JSONAPI.configuration.top_level_meta_include_page_count = false - JSONAPI.configuration.top_level_meta_page_count_key = :page_count + Api::V2::BookResource.paginator :paged + JSONAPI.configuration.top_level_meta_include_page_count = true + JSONAPI.configuration.top_level_meta_page_count_key = 'total_pages' - assert_response :success - assert_equal 91, json_response['meta']['total-pages'] - assert_equal 10, json_response['data'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + assert_cacheable_get :index, params: { include: 'book-comments' } + JSONAPI.configuration.top_level_meta_include_page_count = false + JSONAPI.configuration.top_level_meta_page_count_key = :page_count + + assert_response :success + assert_equal 91, json_response['meta']['total-pages'] + assert_equal 10, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + end end def test_books_offset_pagination_no_params_includes_query_count_one_level Api::V2::BookResource.paginator :offset - assert_query_count(5) do - assert_cacheable_get :index, params: {include: 'book-comments'} + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key + assert_query_count(testing_v10? ? 5 : 3) do + assert_cacheable_get :index, params: { include: 'book-comments' } + end + assert_response :success + assert_equal 10, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] end - assert_response :success - assert_equal 10, json_response['data'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] end def test_books_offset_pagination_no_params_includes_query_count_two_levels Api::V2::BookResource.paginator :offset + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key - assert_query_count(7) do - assert_cacheable_get :index, params: {include: 'book-comments,book-comments.author'} + assert_query_count(testing_v10? ? 7 : 4) do + assert_cacheable_get :index, params: { include: 'book-comments,book-comments.author' } + end + assert_response :success + assert_equal 10, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] end - assert_response :success - assert_equal 10, json_response['data'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] end def test_books_offset_pagination Api::V2::BookResource.paginator :offset - assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}} + assert_cacheable_get :index, params: { page: { offset: 50, limit: 12 } } assert_response :success assert_equal 12, json_response['data'].size assert_equal 'Book 50', json_response['data'][0]['attributes']['title'] @@ -3319,7 +3338,7 @@ def test_books_offset_pagination def test_books_offset_pagination_bad_page_param Api::V2::BookResource.paginator :offset - assert_cacheable_get :index, params: {page: {offset_bad: 50, limit: 12}} + assert_cacheable_get :index, params: { page: { offset_bad: 50, limit: 12 } } assert_response :bad_request assert_match /offset_bad is not an allowed page parameter./, json_response['errors'][0]['detail'] end @@ -3327,7 +3346,7 @@ def test_books_offset_pagination_bad_page_param def test_books_offset_pagination_bad_param_value_limit_to_large Api::V2::BookResource.paginator :offset - assert_cacheable_get :index, params: {page: {offset: 50, limit: 1000}} + assert_cacheable_get :index, params: { page: { offset: 50, limit: 1000 } } assert_response :bad_request assert_match /Limit exceeds maximum page size of 20./, json_response['errors'][0]['detail'] end @@ -3335,7 +3354,7 @@ def test_books_offset_pagination_bad_param_value_limit_to_large def test_books_offset_pagination_bad_param_value_limit_too_small Api::V2::BookResource.paginator :offset - assert_cacheable_get :index, params: {page: {offset: 50, limit: -1}} + assert_cacheable_get :index, params: { page: { offset: 50, limit: -1 } } assert_response :bad_request assert_match /-1 is not a valid value for limit page parameter./, json_response['errors'][0]['detail'] end @@ -3343,7 +3362,7 @@ def test_books_offset_pagination_bad_param_value_limit_too_small def test_books_offset_pagination_bad_param_offset_less_than_zero Api::V2::BookResource.paginator :offset - assert_cacheable_get :index, params: {page: {offset: -1, limit: 20}} + assert_cacheable_get :index, params: { page: { offset: -1, limit: 20 } } assert_response :bad_request assert_match /-1 is not a valid value for offset page parameter./, json_response['errors'][0]['detail'] end @@ -3351,7 +3370,7 @@ def test_books_offset_pagination_bad_param_offset_less_than_zero def test_books_offset_pagination_invalid_page_format Api::V2::BookResource.paginator :offset - assert_cacheable_get :index, params: {page: 50} + assert_cacheable_get :index, params: { page: 50 } assert_response :bad_request assert_match /Invalid Page Object./, json_response['errors'][0]['detail'] end @@ -3368,7 +3387,7 @@ def test_books_paged_pagination_no_params def test_books_paged_pagination_no_page Api::V2::BookResource.paginator :paged - assert_cacheable_get :index, params: {page: {size: 12}} + assert_cacheable_get :index, params: { page: { size: 12 } } assert_response :success assert_equal 12, json_response['data'].size assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] @@ -3377,7 +3396,7 @@ def test_books_paged_pagination_no_page def test_books_paged_pagination Api::V2::BookResource.paginator :paged - assert_cacheable_get :index, params: {page: {number: 3, size: 12}} + assert_cacheable_get :index, params: { page: { number: 3, size: 12 } } assert_response :success assert_equal 12, json_response['data'].size assert_equal 'Book 24', json_response['data'][0]['attributes']['title'] @@ -3386,7 +3405,7 @@ def test_books_paged_pagination def test_books_paged_pagination_bad_page_param Api::V2::BookResource.paginator :paged - assert_cacheable_get :index, params: {page: {number_bad: 50, size: 12}} + assert_cacheable_get :index, params: { page: { number_bad: 50, size: 12 } } assert_response :bad_request assert_match /number_bad is not an allowed page parameter./, json_response['errors'][0]['detail'] end @@ -3394,7 +3413,7 @@ def test_books_paged_pagination_bad_page_param def test_books_paged_pagination_bad_param_value_limit_to_large Api::V2::BookResource.paginator :paged - assert_cacheable_get :index, params: {page: {number: 50, size: 1000}} + assert_cacheable_get :index, params: { page: { number: 50, size: 1000 } } assert_response :bad_request assert_match /size exceeds maximum page size of 20./, json_response['errors'][0]['detail'] end @@ -3402,7 +3421,7 @@ def test_books_paged_pagination_bad_param_value_limit_to_large def test_books_paged_pagination_bad_param_value_limit_too_small Api::V2::BookResource.paginator :paged - assert_cacheable_get :index, params: {page: {number: 50, size: -1}} + assert_cacheable_get :index, params: { page: { number: 50, size: -1 } } assert_response :bad_request assert_match /-1 is not a valid value for size page parameter./, json_response['errors'][0]['detail'] end @@ -3410,161 +3429,188 @@ def test_books_paged_pagination_bad_param_value_limit_too_small def test_books_paged_pagination_invalid_page_format_incorrect Api::V2::BookResource.paginator :paged - assert_cacheable_get :index, params: {page: 'qwerty'} + assert_cacheable_get :index, params: { page: 'qwerty' } assert_response :bad_request assert_match /0 is not a valid value for number page parameter./, json_response['errors'][0]['detail'] end def test_books_paged_pagination_invalid_page_format_interpret_int Api::V2::BookResource.paginator :paged + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key - assert_cacheable_get :index, params: {page: 3} - assert_response :success - assert_equal 10, json_response['data'].size - assert_equal 'Book 20', json_response['data'][0]['attributes']['title'] + assert_cacheable_get :index, params: { page: 3 } + assert_response :success + assert_equal 10, json_response['data'].size + assert_equal 'Book 20', json_response['data'][0]['attributes']['title'] + end end def test_books_included_paged Api::V2::BookResource.paginator :offset - assert_query_count(5) do - assert_cacheable_get :index, params: {filter: {id: '0'}, include: 'book-comments'} - assert_response :success - assert_equal 1, json_response['data'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key + assert_query_count(testing_v10? ? 5 : 3) do + assert_cacheable_get :index, params: { filter: { id: '0' }, include: 'book-comments' } + assert_response :success + assert_equal 1, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + end end end def test_books_banned_non_book_admin $test_user = Person.find(1001) - Api::V2::BookResource.paginator :offset - JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(3) do - assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}} - assert_response :success - assert_equal 12, json_response['data'].size - assert_equal 'Book 50', json_response['data'][0]['attributes']['title'] - assert_equal 901, json_response['meta']['record-count'] + + with_jsonapi_config_changes do + Api::V2::BookResource.paginator :offset + JSONAPI.configuration.top_level_meta_include_record_count = true + JSONAPI.configuration.json_key_format = :dasherized_key + + assert_query_count(testing_v10? ? 3 : 2) do + assert_cacheable_get :index, params: { page: { offset: 50, limit: 12 } } + assert_response :success + assert_equal 12, json_response['data'].size + assert_equal 'Book 50', json_response['data'][0]['attributes']['title'] + assert_equal 901, json_response['meta']['record-count'] + end end - ensure - JSONAPI.configuration.top_level_meta_include_record_count = false end def test_books_banned_non_book_admin_includes_switched $test_user = Person.find(1001) - Api::V2::BookResource.paginator :offset - JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(5) do - assert_cacheable_get :index, params: {page: {offset: 0, limit: 12}, include: 'book-comments'} - assert_response :success - assert_equal 12, json_response['data'].size - assert_equal 130, json_response['included'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] - assert_equal 26, json_response['data'][0]['relationships']['book-comments']['data'].size - assert_equal 'book-comments', json_response['included'][0]['type'] - assert_equal 901, json_response['meta']['record-count'] + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key + + Api::V2::BookResource.paginator :offset + JSONAPI.configuration.top_level_meta_include_record_count = true + assert_query_count(testing_v10? ? 5 : 3) do + assert_cacheable_get :index, params: { page: { offset: 0, limit: 12 }, include: 'book-comments' } + assert_response :success + assert_equal 12, json_response['data'].size + assert_equal 130, json_response['included'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + assert_equal 26, json_response['data'][0]['relationships']['book-comments']['data'].size + assert_equal 'book-comments', json_response['included'][0]['type'] + assert_equal 901, json_response['meta']['record-count'] + end end - ensure - JSONAPI.configuration.top_level_meta_include_record_count = false end def test_books_banned_non_book_admin_includes_nested_includes $test_user = Person.find(1001) - JSONAPI.configuration.top_level_meta_include_record_count = true - Api::V2::BookResource.paginator :offset - assert_query_count(7) do - assert_cacheable_get :index, params: {page: {offset: 0, limit: 12}, include: 'book-comments.author'} - assert_response :success - assert_equal 12, json_response['data'].size - assert_equal 132, json_response['included'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] - assert_equal 901, json_response['meta']['record-count'] + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key + JSONAPI.configuration.top_level_meta_include_record_count = true + Api::V2::BookResource.paginator :offset + assert_query_count(testing_v10? ? 7 : 4) do + assert_cacheable_get :index, params: { page: { offset: 0, limit: 12 }, include: 'book-comments.author' } + assert_response :success + assert_equal 12, json_response['data'].size + assert_equal 132, json_response['included'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + assert_equal 901, json_response['meta']['record-count'] + end end - ensure - JSONAPI.configuration.top_level_meta_include_record_count = false end def test_books_banned_admin $test_user = Person.find(1005) - Api::V2::BookResource.paginator :offset - JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(3) do - assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}, filter: {banned: 'true'}} + + with_jsonapi_config_changes do + Api::V2::BookResource.paginator :offset + JSONAPI.configuration.json_key_format = :dasherized_key + JSONAPI.configuration.top_level_meta_include_record_count = true + assert_query_count(testing_v10? ? 3 : 2) do + assert_cacheable_get :index, params: { page: { offset: 50, limit: 12 }, filter: { banned: 'true' } } + end + assert_response :success + assert_equal 12, json_response['data'].size + assert_equal 'Book 651', json_response['data'][0]['attributes']['title'] + assert_equal 99, json_response['meta']['record-count'] end - assert_response :success - assert_equal 12, json_response['data'].size - assert_equal 'Book 651', json_response['data'][0]['attributes']['title'] - assert_equal 99, json_response['meta']['record-count'] - ensure - JSONAPI.configuration.top_level_meta_include_record_count = false end def test_books_not_banned_admin $test_user = Person.find(1005) - Api::V2::BookResource.paginator :offset - JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(3) do - assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}, filter: {banned: 'false'}, fields: {books: 'id,title'}} + + with_jsonapi_config_changes do + Api::V2::BookResource.paginator :offset + JSONAPI.configuration.json_key_format = :dasherized_key + JSONAPI.configuration.top_level_meta_include_record_count = true + assert_query_count(testing_v10? ? 3 : 2) do + assert_cacheable_get :index, params: { page: { offset: 50, limit: 12 }, filter: { banned: 'false' }, fields: { books: 'id,title' } } + end + assert_response :success + assert_equal 12, json_response['data'].size + assert_equal 'Book 50', json_response['data'][0]['attributes']['title'] + assert_equal 901, json_response['meta']['record-count'] end - assert_response :success - assert_equal 12, json_response['data'].size - assert_equal 'Book 50', json_response['data'][0]['attributes']['title'] - assert_equal 901, json_response['meta']['record-count'] - ensure - JSONAPI.configuration.top_level_meta_include_record_count = false end def test_books_banned_non_book_admin_overlapped $test_user = Person.find(1001) - Api::V2::BookResource.paginator :offset - JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(3) do - assert_cacheable_get :index, params: {page: {offset: 590, limit: 20}} + + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key + + Api::V2::BookResource.paginator :offset + JSONAPI.configuration.top_level_meta_include_record_count = true + assert_query_count(testing_v10? ? 3 : 2) do + assert_cacheable_get :index, params: { page: { offset: 590, limit: 20 } } + end + assert_response :success + assert_equal 20, json_response['data'].size + assert_equal 'Book 590', json_response['data'][0]['attributes']['title'] + assert_equal 901, json_response['meta']['record-count'] end - assert_response :success - assert_equal 20, json_response['data'].size - assert_equal 'Book 590', json_response['data'][0]['attributes']['title'] - assert_equal 901, json_response['meta']['record-count'] - ensure - JSONAPI.configuration.top_level_meta_include_record_count = false end def test_books_included_exclude_unapproved $test_user = Person.find(1001) Api::V2::BookResource.paginator :none - assert_query_count(4) do - assert_cacheable_get :index, params: {filter: {id: '0,1,2,3,4'}, include: 'book-comments'} + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key + + assert_query_count(testing_v10? ? 4 : 2) do + assert_cacheable_get :index, params: { filter: { id: '0,1,2,3,4' }, include: 'book-comments' } + end + assert_response :success + assert_equal 5, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + assert_equal 130, json_response['included'].size + assert_equal 26, json_response['data'][0]['relationships']['book-comments']['data'].size end - assert_response :success - assert_equal 5, json_response['data'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] - assert_equal 130, json_response['included'].size - assert_equal 26, json_response['data'][0]['relationships']['book-comments']['data'].size end def test_books_included_all_comments_for_admin $test_user = Person.find(1005) Api::V2::BookResource.paginator :none - assert_cacheable_get :index, params: {filter: {id: '0,1,2,3,4'}, include: 'book-comments'} - assert_response :success - assert_equal 5, json_response['data'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] - assert_equal 255, json_response['included'].size - assert_equal 51, json_response['data'][0]['relationships']['book-comments']['data'].size + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :dasherized_key + + assert_cacheable_get :index, params: { filter: { id: '0,1,2,3,4' }, include: 'book-comments' } + assert_response :success + assert_equal 5, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + assert_equal 255, json_response['included'].size + assert_equal 51, json_response['data'][0]['relationships']['book-comments']['data'].size + end end def test_books_filter_by_book_comment_id_limited_user $test_user = Person.find(1001) - assert_cacheable_get :index, params: {filter: {book_comments: '0,52' }} + assert_cacheable_get :index, params: { filter: { book_comments: '0,52' } } assert_response :success assert_equal 1, json_response['data'].size end def test_books_filter_by_book_comment_id_admin_user $test_user = Person.find(1005) - assert_cacheable_get :index, params: {filter: {book_comments: '0,52' }} + assert_cacheable_get :index, params: { filter: { book_comments: '0,52' } } assert_response :success assert_equal 2, json_response['data'].size end @@ -3574,7 +3620,7 @@ def test_books_create_unapproved_comment_limited_user_using_relation_name $test_user = Person.find(1001) book_comment = BookComment.create(body: 'Not Approved dummy comment', approved: false) - post :create_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} + post :create_relationship, params: { book_id: 1, relationship: 'book_comments', data: [{ type: 'book_comments', id: book_comment.id }] } # Note the not_found response is coming from the BookComment's overridden records method, not the relation assert_response :not_found @@ -3588,7 +3634,7 @@ def test_books_create_approved_comment_limited_user_using_relation_name $test_user = Person.find(1001) book_comment = BookComment.create(body: 'Approved dummy comment', approved: true) - post :create_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} + post :create_relationship, params: { book_id: 1, relationship: 'book_comments', data: [{ type: 'book_comments', id: book_comment.id }] } assert_response :success ensure @@ -3599,7 +3645,7 @@ def test_books_delete_unapproved_comment_limited_user_using_relation_name $test_user = Person.find(1001) book_comment = BookComment.create(book_id: 1, body: 'Not Approved dummy comment', approved: false) - delete :destroy_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} + delete :destroy_relationship, params: { book_id: 1, relationship: 'book_comments', data: [{ type: 'book_comments', id: book_comment.id }] } assert_response :not_found ensure @@ -3610,7 +3656,7 @@ def test_books_delete_approved_comment_limited_user_using_relation_name $test_user = Person.find(1001) book_comment = BookComment.create(book_id: 1, body: 'Approved dummy comment', approved: true) - delete :destroy_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} + delete :destroy_relationship, params: { book_id: 1, relationship: 'book_comments', data: [{ type: 'book_comments', id: book_comment.id }] } assert_response :no_content ensure @@ -3618,16 +3664,16 @@ def test_books_delete_approved_comment_limited_user_using_relation_name end def test_books_delete_approved_comment_limited_user_using_relation_name_reflected - JSONAPI.configuration.use_relationship_reflection = true $test_user = Person.find(1001) - book_comment = BookComment.create(book_id: 1, body: 'Approved dummy comment', approved: true) - delete :destroy_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} - assert_response :no_content - - ensure - JSONAPI.configuration.use_relationship_reflection = false - book_comment.delete + with_jsonapi_config_changes do + JSONAPI.configuration.use_relationship_reflection = true + book_comment = BookComment.create(book_id: 1, body: 'Approved dummy comment', approved: true) + delete :destroy_relationship, params: { book_id: 1, relationship: 'book_comments', data: [{ type: 'book_comments', id: book_comment.id }] } + assert_response :no_content + ensure + book_comment.delete + end end def test_index_related_resources_pagination @@ -3643,14 +3689,13 @@ def test_index_related_resources_pagination class Api::V2::BookCommentsControllerTest < ActionController::TestCase def setup - JSONAPI.configuration.json_key_format = :dasherized_key Api::V2::BookCommentResource.paginator :none $test_user = Person.find(1001) end def test_book_comments_all_for_admin $test_user = Person.find(1005) - assert_query_count(2) do + assert_query_count(testing_v10? ? 2 : 1) do assert_cacheable_get :index end assert_response :success @@ -3659,8 +3704,8 @@ def test_book_comments_all_for_admin def test_book_comments_unapproved_context_based $test_user = Person.find(1005) - assert_query_count(2) do - assert_cacheable_get :index, params: {filter: {approved: 'false'}} + assert_query_count(testing_v10? ? 2 : 1) do + assert_cacheable_get :index, params: { filter: { approved: 'false' } } end assert_response :success assert_equal 125, json_response['data'].size @@ -3668,7 +3713,7 @@ def test_book_comments_unapproved_context_based def test_book_comments_exclude_unapproved_context_based $test_user = Person.find(1001) - assert_query_count(2) do + assert_query_count(testing_v10? ? 2 : 1) do assert_cacheable_get :index end assert_response :success @@ -3678,24 +3723,23 @@ def test_book_comments_exclude_unapproved_context_based class Api::V4::PostsControllerTest < ActionController::TestCase def test_warn_on_joined_to_many - original_config = JSONAPI.configuration.dup + skip("Need to reevaluate the appropriateness of this test") - JSONAPI.configuration.warn_on_performance_issues = true - _out, err = capture_subprocess_io do - get :index, params: {fields: {posts: 'id,title'}} - assert_response :success - end - assert_equal(err, "Performance issue detected: `Api::V4::PostResource.records` returned non-normalized results in `Api::V4::PostResource.find_fragments`.\n") + with_jsonapi_config_changes do + JSONAPI.configuration.warn_on_performance_issues = true + _out, err = capture_subprocess_io do + get :index, params: { fields: { posts: 'id,title' } } + assert_response :success + end + assert_equal(err, "Performance issue detected: `Api::V4::PostResource.records` returned non-normalized results in `Api::V4::PostResource.find_fragments`.\n") - JSONAPI.configuration.warn_on_performance_issues = false - _out, err = capture_subprocess_io do - get :index, params: {fields: {posts: 'id,title'}} - assert_response :success + JSONAPI.configuration.warn_on_performance_issues = false + _out, err = capture_subprocess_io do + get :index, params: { fields: { posts: 'id,title' } } + assert_response :success + end + assert_empty err end - assert_empty err - - ensure - JSONAPI.configuration = original_config end end @@ -3705,15 +3749,14 @@ def setup end def test_books_offset_pagination_meta - original_config = JSONAPI.configuration.dup - Api::V4::BookResource.paginator :offset - assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}} - assert_response :success - assert_equal 12, json_response['data'].size - assert_equal 'Book 50', json_response['data'][0]['attributes']['title'] - assert_equal 901, json_response['meta']['totalRecords'] - ensure - JSONAPI.configuration = original_config + with_jsonapi_config_changes do + Api::V4::BookResource.paginator :offset + assert_cacheable_get :index, params: { page: { offset: 50, limit: 12 } } + assert_response :success + assert_equal 12, json_response['data'].size + assert_equal 'Book 50', json_response['data'][0]['attributes']['title'] + assert_equal 901, json_response['meta']['totalRecords'] + end end def test_inherited_pagination @@ -3721,16 +3764,15 @@ def test_inherited_pagination end def test_books_operation_links - original_config = JSONAPI.configuration.dup - Api::V4::BookResource.paginator :offset - assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}} - assert_response :success - assert_equal 12, json_response['data'].size - assert_equal 'Book 50', json_response['data'][0]['attributes']['title'] - assert_equal 5, json_response['links'].size - assert_equal 'https://test_corp.com', json_response['links']['spec'] - ensure - JSONAPI.configuration = original_config + with_jsonapi_config_changes do + Api::V4::BookResource.paginator :offset + assert_cacheable_get :index, params: { page: { offset: 50, limit: 12 } } + assert_response :success + assert_equal 12, json_response['data'].size + assert_equal 'Book 50', json_response['data'][0]['attributes']['title'] + assert_equal 5, json_response['links'].size + assert_equal 'https://test_corp.com', json_response['links']['spec'] + end end end @@ -3789,60 +3831,59 @@ def test_save_model_callbacks_fail class Api::V1::MoonsControllerTest < ActionController::TestCase def test_show_related_resource - assert_cacheable_get :show_related_resource, params: {crater_id: 'S56D', relationship: 'moon', source: "api/v1/craters"} + assert_cacheable_get :show_related_resource, params: { crater_id: 'S56D', relationship: 'moon', source: "api/v1/craters" } assert_response :success assert_hash_equals({ data: { id: "1", type: "moons", - links: {self: "http://test.host/api/v1/moons/1"}, - attributes: {name: "Titan", description: "Best known of the Saturn moons."}, + links: { self: "http://test.host/api/v1/moons/1" }, + attributes: { name: "Titan", description: "Best known of the Saturn moons." }, relationships: { - planet: {links: {self: "http://test.host/api/v1/moons/1/relationships/planet", related: "http://test.host/api/v1/moons/1/planet"}}, - craters: {links: {self: "http://test.host/api/v1/moons/1/relationships/craters", related: "http://test.host/api/v1/moons/1/craters"}}} + planet: { links: { self: "http://test.host/api/v1/moons/1/relationships/planet", related: "http://test.host/api/v1/moons/1/planet" } }, + craters: { links: { self: "http://test.host/api/v1/moons/1/relationships/craters", related: "http://test.host/api/v1/moons/1/craters" } } } } }, json_response) end def test_show_related_resource_to_one_linkage_data - JSONAPI.configuration.always_include_to_one_linkage_data = true + with_jsonapi_config_changes do + JSONAPI.configuration.always_include_to_one_linkage_data = true - assert_cacheable_get :show_related_resource, params: {crater_id: 'S56D', relationship: 'moon', source: "api/v1/craters"} - assert_response :success - assert_hash_equals({ + assert_cacheable_get :show_related_resource, params: { crater_id: 'S56D', relationship: 'moon', source: "api/v1/craters" } + assert_response :success + assert_hash_equals({ data: { - id: "1", - type: "moons", - links: {self: "http://test.host/api/v1/moons/1"}, - attributes: {name: "Titan", description: "Best known of the Saturn moons."}, - relationships: { - planet: {links: {self: "http://test.host/api/v1/moons/1/relationships/planet", - related: "http://test.host/api/v1/moons/1/planet"}, - data: {type: "planets", id: "1"} - }, - craters: {links: {self: "http://test.host/api/v1/moons/1/relationships/craters", related: "http://test.host/api/v1/moons/1/craters"}}} + id: "1", + type: "moons", + links: { self: "http://test.host/api/v1/moons/1" }, + attributes: { name: "Titan", description: "Best known of the Saturn moons." }, + relationships: { + planet: { links: { self: "http://test.host/api/v1/moons/1/relationships/planet", + related: "http://test.host/api/v1/moons/1/planet" }, + data: { type: "planets", id: "1" } + }, + craters: { links: { self: "http://test.host/api/v1/moons/1/relationships/craters", related: "http://test.host/api/v1/moons/1/craters" } } } } - }, json_response) - ensure - JSONAPI.configuration.always_include_to_one_linkage_data = false + }, json_response) + end end def test_index_related_resources_with_select_some_db_columns Api::V1::MoonResource.paginator :paged - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.top_level_meta_include_record_count = true - JSONAPI.configuration.json_key_format = :dasherized_key - assert_cacheable_get :index_related_resources, params: {planet_id: '1', relationship: 'moons', source: 'api/v1/planets'} - assert_response :success - assert_equal 1, json_response['meta']['record-count'] - ensure - JSONAPI.configuration = original_config + with_jsonapi_config_changes do + JSONAPI.configuration.top_level_meta_include_record_count = true + JSONAPI.configuration.json_key_format = :dasherized_key + assert_cacheable_get :index_related_resources, params: { planet_id: '1', relationship: 'moons', source: 'api/v1/planets' } + assert_response :success + assert_equal 1, json_response['meta']['record-count'] + end end end class Api::V1::CratersControllerTest < ActionController::TestCase def test_show_single - assert_cacheable_get :show, params: {id: 'S56D'} + assert_cacheable_get :show, params: { id: 'S56D' } assert_response :success assert json_response['data'].is_a?(Hash) assert_equal 'S56D', json_response['data']['attributes']['code'] @@ -3851,23 +3892,23 @@ def test_show_single end def test_index_related_resources - assert_cacheable_get :index_related_resources, params: {moon_id: '1', relationship: 'craters', source: "api/v1/moons"} + assert_cacheable_get :index_related_resources, params: { moon_id: '1', relationship: 'craters', source: "api/v1/moons" } assert_response :success assert_hash_equals({ data: [ { - id:"A4D3", - type:"craters", - links:{self: "http://test.host/api/v1/craters/A4D3"}, - attributes:{code: "A4D3", description: "Small crater"}, - relationships:{moon: {links: {self: "http://test.host/api/v1/craters/A4D3/relationships/moon", related: "http://test.host/api/v1/craters/A4D3/moon"}}} + id: "A4D3", + type: "craters", + links: { self: "http://test.host/api/v1/craters/A4D3" }, + attributes: { code: "A4D3", description: "Small crater" }, + relationships: { moon: { links: { self: "http://test.host/api/v1/craters/A4D3/relationships/moon", related: "http://test.host/api/v1/craters/A4D3/moon" } } } }, { id: "S56D", type: "craters", - links:{self: "http://test.host/api/v1/craters/S56D"}, - attributes:{code: "S56D", description: "Very large crater"}, - relationships:{moon: {links: {self: "http://test.host/api/v1/craters/S56D/relationships/moon", related: "http://test.host/api/v1/craters/S56D/moon"}}} + links: { self: "http://test.host/api/v1/craters/S56D" }, + attributes: { code: "S56D", description: "Very large crater" }, + relationships: { moon: { links: { self: "http://test.host/api/v1/craters/S56D/relationships/moon", related: "http://test.host/api/v1/craters/S56D/moon" } } } } ] }, json_response) @@ -3877,35 +3918,35 @@ def test_index_related_resources_filtered $test_user = Person.find(1001) assert_cacheable_get :index_related_resources, params: { - moon_id: '1', - relationship: 'craters', - source: "api/v1/moons", - filter: { description: 'Small crater' } + moon_id: '1', + relationship: 'craters', + source: "api/v1/moons", + filter: { description: 'Small crater' } } assert_response :success assert_hash_equals({ - data: [ - { - id:"A4D3", - type:"craters", - links:{self: "http://test.host/api/v1/craters/A4D3"}, - attributes:{code: "A4D3", description: "Small crater"}, - relationships: { - moon: { - links: { - self: "http://test.host/api/v1/craters/A4D3/relationships/moon", - related: "http://test.host/api/v1/craters/A4D3/moon" - } - } - } + data: [ + { + id: "A4D3", + type: "craters", + links: { self: "http://test.host/api/v1/craters/A4D3" }, + attributes: { code: "A4D3", description: "Small crater" }, + relationships: { + moon: { + links: { + self: "http://test.host/api/v1/craters/A4D3/relationships/moon", + related: "http://test.host/api/v1/craters/A4D3/moon" + } } - ] + } + } + ] }, json_response) end def test_show_relationship - assert_cacheable_get :show_relationship, params: {crater_id: 'S56D', relationship: 'moon'} + assert_cacheable_get :show_relationship, params: { crater_id: 'S56D', relationship: 'moon' } assert_response :success assert_equal "moons", json_response['data']['type'] @@ -3914,43 +3955,40 @@ def test_show_relationship end class CarsControllerTest < ActionController::TestCase - def setup - JSONAPI.configuration.json_key_format = :camelized_key - end - def test_create_sti - set_content_type_header! - post :create, params: - { - data: { - type: 'cars', - attributes: { - make: 'Toyota', - model: 'Tercel', - serialNumber: 'asasdsdadsa13544235', - driveLayout: 'FWD' + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + + set_content_type_header! + post :create, params: + { + data: { + type: 'cars', + attributes: { + make: 'Toyota', + model: 'Tercel', + serialNumber: 'asasdsdadsa13544235', + driveLayout: 'FWD' + } } } - } - assert_response :created - assert json_response['data'].is_a?(Hash) - assert_equal 'cars', json_response['data']['type'] - assert_equal 'Toyota', json_response['data']['attributes']['make'] - assert_equal 'FWD', json_response['data']['attributes']['driveLayout'] + assert_response :created + assert json_response['data'].is_a?(Hash) + assert_equal 'cars', json_response['data']['type'] + assert_equal 'Toyota', json_response['data']['attributes']['make'] + assert_equal 'FWD', json_response['data']['attributes']['driveLayout'] + end end end class VehiclesControllerTest < ActionController::TestCase - def setup - JSONAPI.configuration.json_key_format = :camelized_key - end - def test_STI_index_returns_all_types - assert_cacheable_get :index + get :index assert_response :success - assert_equal 'cars', json_response['data'][0]['type'] - assert_equal 'boats', json_response['data'][1]['type'] + types = json_response['data'].collect { |d| d['type'] }.to_set + assert types.include?('cars') + assert types.include?('boats') end def test_immutable_create_not_supported @@ -4018,59 +4056,34 @@ def test_get_namespaced_model_matching_resource class Api::V7::CategoriesControllerTest < ActionController::TestCase def test_uncaught_error_in_controller_translated_to_internal_server_error - get :show, params: {id: '1'} + get :show, params: { id: '1' } assert_response 500 assert_match /Internal Server Error/, json_response['errors'][0]['detail'] end def test_not_allowed_error_in_controller - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.exception_class_allowlist = [] - get :show, params: {id: '1'} - assert_response 500 - assert_match /Internal Server Error/, json_response['errors'][0]['detail'] - ensure - JSONAPI.configuration = original_config - end - - def test_not_whitelisted_error_in_controller - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.exception_class_whitelist = [] - get :show, params: {id: '1'} - assert_response 500 - assert_match /Internal Server Error/, json_response['errors'][0]['detail'] - ensure - JSONAPI.configuration = original_config - end - - def test_allowed_error_in_controller - original_config = JSONAPI.configuration.dup - $PostProcessorRaisesErrors = true - JSONAPI.configuration.exception_class_allowlist = [PostsController::SubSpecialError] - assert_raises PostsController::SubSpecialError do - assert_cacheable_get :show, params: {id: '1'} + with_jsonapi_config_changes do + JSONAPI.configuration.exception_class_allowlist = [] + get :show, params: { id: '1' } + assert_response 500 + assert_match /Internal Server Error/, json_response['errors'][0]['detail'] end - ensure - JSONAPI.configuration = original_config - $PostProcessorRaisesErrors = false end - def test_whitelisted_error_in_controller - original_config = JSONAPI.configuration.dup - $PostProcessorRaisesErrors = true - JSONAPI.configuration.exception_class_whitelist = [PostsController::SubSpecialError] - assert_raises PostsController::SubSpecialError do - assert_cacheable_get :show, params: {id: '1'} + def test_allowed_error_in_controller + with_jsonapi_config_changes do + $PostProcessorRaisesErrors = true + JSONAPI.configuration.exception_class_allowlist = [PostsController::SubSpecialError] + assert_raises PostsController::SubSpecialError do + assert_cacheable_get :show, params: { id: '1' } + end end - ensure - JSONAPI.configuration = original_config - $PostProcessorRaisesErrors = false end end class Api::V6::PostsControllerTest < ActionController::TestCase def test_caching_with_join_from_resource_with_sql_fragment - assert_cacheable_get :index, params: {include: 'section'} + assert_cacheable_get :index, params: { include: 'section' } assert_response :success end @@ -4086,14 +4099,14 @@ def test_delete_with_validation_error_base_on_resource class Api::V6::SectionsControllerTest < ActionController::TestCase def test_caching_with_join_to_resource_with_sql_fragment - assert_cacheable_get :index, params: {include: 'posts'} + assert_cacheable_get :index, params: { include: 'posts' } assert_response :success end end class AuthorsControllerTest < ActionController::TestCase def test_show_author_recursive - assert_cacheable_get :show, params: {id: '1002', include: 'books.authors'} + assert_cacheable_get :show, params: { id: '1002', include: 'books.authors' } assert_response :success assert_equal '1002', json_response['data']['id'] assert_equal 'authors', json_response['data']['type'] @@ -4108,7 +4121,7 @@ def test_show_author_recursive end def test_show_author_do_not_include_polymorphic_linkage - assert_cacheable_get :show, params: {id: '1002', include: 'pictures'} + assert_cacheable_get :show, params: { id: '1002', include: 'pictures' } assert_response :success assert_equal '1002', json_response['data']['id'] assert_equal 'authors', json_response['data']['type'] @@ -4118,19 +4131,22 @@ def test_show_author_do_not_include_polymorphic_linkage end def test_show_author_include_polymorphic_linkage - JSONAPI.configuration.always_include_to_one_linkage_data = true + with_jsonapi_config_changes do + JSONAPI.configuration.always_include_to_one_linkage_data = true - assert_cacheable_get :show, params: {id: '1002', include: 'pictures'} - assert_response :success - assert_equal '1002', json_response['data']['id'] - assert_equal 'authors', json_response['data']['type'] - assert_equal 'Fred Reader', json_response['data']['attributes']['name'] - assert json_response['included'][0]['relationships']['imageable']['links'] - assert json_response['included'][0]['relationships']['imageable']['data'] - assert_equal 'products', json_response['included'][0]['relationships']['imageable']['data']['type'] - assert_equal '1', json_response['included'][0]['relationships']['imageable']['data']['id'] - ensure - JSONAPI.configuration.always_include_to_one_linkage_data = false + assert_cacheable_get :show, params: { id: '1002', include: 'pictures' } + assert_response :success + assert_equal '1002', json_response['data']['id'] + assert_equal 'authors', json_response['data']['type'] + assert_equal 'Fred Reader', json_response['data']['attributes']['name'] + assert json_response['included'][0]['relationships']['imageable']['links'] + assert json_response['included'][0]['relationships']['imageable']['data'] + assert_equal 'products', json_response['included'][0]['relationships']['imageable']['data']['type'] + assert_equal '1', json_response['included'][0]['relationships']['imageable']['data']['id'] + + refute json_response['included'][0]['relationships'].keys.include?('product') + refute json_response['included'][0]['relationships'].keys.include?('document') + end end end @@ -4139,12 +4155,12 @@ def test_cache_pollution_for_non_admin_indirect_access_to_banned_books cache = ActiveSupport::Cache::MemoryStore.new with_resource_caching(cache) do $test_user = Person.find(1005) - get :show, params: {id: '1002', include: 'books'} + get :show, params: { id: '1002', include: 'books' } assert_response :success assert_equal 2, json_response['included'].length $test_user = Person.find(1001) - get :show, params: {id: '1002', include: 'books'} + get :show, params: { id: '1002', include: 'books' } assert_response :success assert_equal 1, json_response['included'].length end @@ -4158,7 +4174,7 @@ def test_complex_includes_base end def test_complex_includes_filters_nil_includes - assert_cacheable_get :index, params: {include: ',,'} + assert_cacheable_get :index, params: { include: ',,' } assert_response :success end @@ -4166,652 +4182,679 @@ def test_complex_includes_two_level if is_db?(:mysql) skip "#{adapter_name} test expectations differ in insignificant ways from expected" end - assert_cacheable_get :index, params: {include: 'things,things.user'} + assert_cacheable_get :index, params: { include: 'things,things.user' } assert_response :success - sorted_includeds = json_response['included'].map {|included| + sorted_includeds = json_response['included'].map { |included| { 'id' => included['id'], 'type' => included['type'], 'relationships_user_data_id' => included['relationships'].dig('user', 'data', 'id'), - 'relationships_things_data_ids' => included['relationships'].dig('things', 'data')&.map {|data| data['id'] }&.sort, + 'relationships_things_data_ids' => included['relationships'].dig('things', 'data')&.map { |data| data['id'] }&.sort, } - }.sort_by {|included| "#{included['type']}-#{Integer(included['id'])}" } + }.sort_by { |included| "#{included['type']}-#{Integer(included['id'])}" } expected = [ { - 'id'=>'10', - 'type'=>'things', - 'relationships_user_data_id'=>'10001', - 'relationships_things_data_ids'=>nil + 'id' => '10', + 'type' => 'things', + 'relationships_user_data_id' => '10001', + 'relationships_things_data_ids' => nil }, { - 'id'=>'20', - 'type'=>'things', - 'relationships_user_data_id'=>'10001', - 'relationships_things_data_ids'=>nil + 'id' => '20', + 'type' => 'things', + 'relationships_user_data_id' => '10001', + 'relationships_things_data_ids' => nil }, { - 'id'=>'30', - 'type'=>'things', - 'relationships_user_data_id'=>'10002', - 'relationships_things_data_ids'=>nil + 'id' => '30', + 'type' => 'things', + 'relationships_user_data_id' => '10002', + 'relationships_things_data_ids' => nil }, { - 'id'=>'10001', - 'type'=>'users', - 'relationships_user_data_id'=>nil, - 'relationships_things_data_ids'=>['10', '20'] + 'id' => '10001', + 'type' => 'users', + 'relationships_user_data_id' => nil, + 'relationships_things_data_ids' => ['10', '20'] }, { - 'id'=>'10002', - 'type'=>'users', - 'relationships_user_data_id'=>nil, - 'relationships_things_data_ids'=>['30'] + 'id' => '10002', + 'type' => 'users', + 'relationships_user_data_id' => nil, + 'relationships_things_data_ids' => ['30'] }, ] assert_array_equals expected, sorted_includeds end def test_complex_includes_things_nested_things - assert_cacheable_get :index, params: {include: 'things,things.things,things.things.things'} + skip "TODO: Issues with new ActiveRelationRetrieval" + + assert_cacheable_get :index, params: { include: 'things,things.things,things.things.things' } assert_response :success sorted_json_response_data = json_response["data"] - .sort_by {|data| Integer(data["id"]) } + .sort_by { |data| Integer(data["id"]) } sorted_json_response_included = json_response["included"] - .sort_by {|included| "#{included['type']}-#{Integer(included['id'])}" } + .sort_by { |included| "#{included['type']}-#{Integer(included['id'])}" } sorted_json_response = { "data" => sorted_json_response_data, "included" => sorted_json_response_included, } expected_response = { - "data" => [ + "data" => [ + { + "id" => "100", + "type" => "boxes", + "links" => { + "self" => "http://test.host/api/boxes/100" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/boxes/100/relationships/things", + "related" => "http://test.host/api/boxes/100/things" + }, + "data" => [ { - "id" => "100", - "type" => "boxes", - "links" => { - "self" => "http://test.host/api/boxes/100" - }, - "relationships" => { - "things" => { - "links" => { - "self" => "http://test.host/api/boxes/100/relationships/things", - "related" => "http://test.host/api/boxes/100/things" - }, - "data" => [ - { - "type" => "things", - "id" => "10" - }, - { - "type" => "things", - "id" => "20" - } - ] - } - } + "type" => "things", + "id" => "10" }, { - "id" => "102", - "type" => "boxes", - "links" => { - "self" => "http://test.host/api/boxes/102" - }, - "relationships" => { - "things" => { - "links" => { - "self" => "http://test.host/api/boxes/102/relationships/things", - "related" => "http://test.host/api/boxes/102/things" - }, - "data" => [ - { - "type" => "things", - "id" => "30" - } - ] - } - } + "type" => "things", + "id" => "20" } - ], - "included" => [ + ] + } + } + }, + { + "id" => "102", + "type" => "boxes", + "links" => { + "self" => "http://test.host/api/boxes/102" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/boxes/102/relationships/things", + "related" => "http://test.host/api/boxes/102/things" + }, + "data" => [ { - "id" => "10", - "type" => "things", - "links" => { - "self" => "http://test.host/api/things/10" - }, - "relationships" => { - "box" => { - "links" => { - "self" => "http://test.host/api/things/10/relationships/box", - "related" => "http://test.host/api/things/10/box" - }, - "data" => { - "type" => "boxes", - "id" => "100" - } - }, - "user" => { - "links" => { - "self" => "http://test.host/api/things/10/relationships/user", - "related" => "http://test.host/api/things/10/user" - } - }, - "things" => { - "links" => { - "self" => "http://test.host/api/things/10/relationships/things", - "related" => "http://test.host/api/things/10/things" - }, - "data" => [ - { - "type" => "things", - "id" => "20" - } - ] - } - } - }, + "type" => "things", + "id" => "30" + } + ] + } + } + } + ], + "included" => [ + { + "id" => "10", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/10" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/box", + "related" => "http://test.host/api/things/10/box" + }, + "data" => { + "type" => "boxes", + "id" => "100" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/user", + "related" => "http://test.host/api/things/10/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/things", + "related" => "http://test.host/api/things/10/things" + }, + "data" => [ { - "id" => "20", - "type" => "things", - "links" => { - "self" => "http://test.host/api/things/20" - }, - "relationships" => { - "box" => { - "links" => { - "self" => "http://test.host/api/things/20/relationships/box", - "related" => "http://test.host/api/things/20/box" - }, - "data" => { - "type" => "boxes", - "id" => "100" - } - }, - "user" => { - "links" => { - "self" => "http://test.host/api/things/20/relationships/user", - "related" => "http://test.host/api/things/20/user" - } - }, - "things" => { - "links" => { - "self" => "http://test.host/api/things/20/relationships/things", - "related" => "http://test.host/api/things/20/things" - }, - "data" => [ - { - "type" => "things", - "id" => "10" - } - ] - } - } - }, + "type" => "things", + "id" => "20" + } + ] + } + } + }, + { + "id" => "20", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/20" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/box", + "related" => "http://test.host/api/things/20/box" + }, + "data" => { + "type" => "boxes", + "id" => "100" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/user", + "related" => "http://test.host/api/things/20/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/things", + "related" => "http://test.host/api/things/20/things" + }, + "data" => [ { - "id" => "30", - "type" => "things", - "links" => { - "self" => "http://test.host/api/things/30" - }, - "relationships" => { - "box" => { - "links" => { - "self" => "http://test.host/api/things/30/relationships/box", - "related" => "http://test.host/api/things/30/box" - }, - "data" => { - "type" => "boxes", - "id" => "102" - } - }, - "user" => { - "links" => { - "self" => "http://test.host/api/things/30/relationships/user", - "related" => "http://test.host/api/things/30/user" - } - }, - "things" => { - "links" => { - "self" => "http://test.host/api/things/30/relationships/things", - "related" => "http://test.host/api/things/30/things" - }, - "data" => [ - { - "type" => "things", - "id" => "40" - }, - { - "type" => "things", - "id" => "50" - } - - ] - } - } - }, + "type" => "things", + "id" => "10" + } + ] + } + } + }, + { + "id" => "30", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/30" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/box", + "related" => "http://test.host/api/things/30/box" + }, + "data" => { + "type" => "boxes", + "id" => "102" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/user", + "related" => "http://test.host/api/things/30/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/things", + "related" => "http://test.host/api/things/30/things" + }, + "data" => [ { - "id" => "40", - "type" => "things", - "links" => { - "self" => "http://test.host/api/things/40" - }, - "relationships" => { - "box" => { - "links" => { - "self" => "http://test.host/api/things/40/relationships/box", - "related" => "http://test.host/api/things/40/box" - } - }, - "user" => { - "links" => { - "self" => "http://test.host/api/things/40/relationships/user", - "related" => "http://test.host/api/things/40/user" - } - }, - "things" => { - "links" => { - "self" => "http://test.host/api/things/40/relationships/things", - "related" => "http://test.host/api/things/40/things" - }, - "data"=>[] - } - } + "type" => "things", + "id" => "40" }, { - "id" => "50", - "type" => "things", - "links" => { - "self" => "http://test.host/api/things/50" - }, - "relationships" => { - "box" => { - "links" => { - "self" => "http://test.host/api/things/50/relationships/box", - "related" => "http://test.host/api/things/50/box" - } - }, - "user" => { - "links" => { - "self" => "http://test.host/api/things/50/relationships/user", - "related" => "http://test.host/api/things/50/user" - } - }, - "things" => { - "links" => { - "self" => "http://test.host/api/things/50/relationships/things", - "related" => "http://test.host/api/things/50/things" - }, - "data" => [ - { - "type" => "things", - "id" => "60" - } - ] - } - } + "type" => "things", + "id" => "50" + } + ] + } + } + }, + { + "id" => "40", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/40" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/box", + "related" => "http://test.host/api/things/40/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/user", + "related" => "http://test.host/api/things/40/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/things", + "related" => "http://test.host/api/things/40/things" + }, + "data" => [ + { + "type" => "things", + "id" => "30" + } + ] + } + } + }, + { + "id" => "50", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/50" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/box", + "related" => "http://test.host/api/things/50/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/user", + "related" => "http://test.host/api/things/50/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/things", + "related" => "http://test.host/api/things/50/things" + }, + "data" => [ + { + "type" => "things", + "id" => "30" }, { - "id" => "60", - "type" => "things", - "links" => { - "self" => "http://test.host/api/things/60" - }, - "relationships" => { - "box" => { - "links" => { - "self" => "http://test.host/api/things/60/relationships/box", - "related" => "http://test.host/api/things/60/box" - } - }, - "user" => { - "links" => { - "self" => "http://test.host/api/things/60/relationships/user", - "related" => "http://test.host/api/things/60/user" - } - }, - "things" => { - "links" => { - "self" => "http://test.host/api/things/60/relationships/things", - "related" => "http://test.host/api/things/60/things" - } - } - } + "type" => "things", + "id" => "60" } - ] + ] + } + } + }, + { + "id" => "60", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/60" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/60/relationships/box", + "related" => "http://test.host/api/things/60/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/60/relationships/user", + "related" => "http://test.host/api/things/60/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/60/relationships/things", + "related" => "http://test.host/api/things/60/things" + }, + "data" => [ + { + "type" => "things", + "id" => "50" + } + ] + } + } } + ] + } assert_hash_equals(expected_response, sorted_json_response) end def test_complex_includes_nested_things_secondary_users + skip "TODO: Issues with new ActiveRelationRetrieval" + if is_db?(:mysql) skip "#{adapter_name} test expectations differ in insignificant ways from expected" end - assert_cacheable_get :index, params: {include: 'things,things.user,things.things'} + assert_cacheable_get :index, params: { include: 'things,things.user,things.things' } assert_response :success sorted_json_response_data = json_response["data"] - .sort_by {|data| Integer(data["id"]) } + .sort_by { |data| Integer(data["id"]) } sorted_json_response_included = json_response["included"] - .sort_by {|included| "#{included['type']}-#{Integer(included['id'])}" } + .sort_by { |included| "#{included['type']}-#{Integer(included['id'])}" } sorted_json_response = { "data" => sorted_json_response_data, "included" => sorted_json_response_included, } expected = - { - "data" => [ - { - "id" => "100", - "type" => "boxes", - "links" => { - "self" => "http://test.host/api/boxes/100" - }, - "relationships" => { - "things" => { - "links" => { - "self" => "http://test.host/api/boxes/100/relationships/things", - "related" => "http://test.host/api/boxes/100/things" - }, - "data" => [ - { - "type" => "things", - "id" => "10" - }, - { - "type" => "things", - "id" => "20" - } - ] - } - } + { + "data" => [ + { + "id" => "100", + "type" => "boxes", + "links" => { + "self" => "http://test.host/api/boxes/100" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/boxes/100/relationships/things", + "related" => "http://test.host/api/boxes/100/things" }, - { - "id" => "102", - "type" => "boxes", - "links" => { - "self" => "http://test.host/api/boxes/102" - }, - "relationships" => { - "things" => { - "links" => { - "self" => "http://test.host/api/boxes/102/relationships/things", - "related" => "http://test.host/api/boxes/102/things" - }, - "data" => [ - { - "type" => "things", - "id" => "30" - } - ] - } - } + "data" => [ + { + "type" => "things", + "id" => "10" + }, + { + "type" => "things", + "id" => "20" + } + ] + } + } + }, + { + "id" => "102", + "type" => "boxes", + "links" => { + "self" => "http://test.host/api/boxes/102" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/boxes/102/relationships/things", + "related" => "http://test.host/api/boxes/102/things" + }, + "data" => [ + { + "type" => "things", + "id" => "30" + } + ] + } + } + } + ], + "included" => [ + { + "id" => "10", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/10" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/box", + "related" => "http://test.host/api/things/10/box" + }, + "data" => { + "type" => "boxes", + "id" => "100" } - ], - "included" => [ - { - "id" => "10", + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/user", + "related" => "http://test.host/api/things/10/user" + }, + "data" => { + "type" => "users", + "id" => "10001" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/things", + "related" => "http://test.host/api/things/10/things" + }, + "data" => [ + { "type" => "things", - "links" => { - "self" => "http://test.host/api/things/10" - }, - "relationships" => { - "box" => { - "links" => { - "self" => "http://test.host/api/things/10/relationships/box", - "related" => "http://test.host/api/things/10/box" - }, - "data" => { - "type" => "boxes", - "id" => "100" - } - }, - "user" => { - "links" => { - "self" => "http://test.host/api/things/10/relationships/user", - "related" => "http://test.host/api/things/10/user" - }, - "data" => { - "type" => "users", - "id" => "10001" - } - }, - "things" => { - "links" => { - "self" => "http://test.host/api/things/10/relationships/things", - "related" => "http://test.host/api/things/10/things" - }, - "data" => [ - { - "type" => "things", - "id" => "20" - } - ] - } - } + "id" => "20" + } + ] + } + } + }, + { + "id" => "20", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/20" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/box", + "related" => "http://test.host/api/things/20/box" }, - { - "id" => "20", + "data" => { + "type" => "boxes", + "id" => "100" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/user", + "related" => "http://test.host/api/things/20/user" + }, + "data" => { + "type" => "users", + "id" => "10001" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/things", + "related" => "http://test.host/api/things/20/things" + }, + "data" => [ + { "type" => "things", - "links" => { - "self" => "http://test.host/api/things/20" - }, - "relationships" => { - "box" => { - "links" => { - "self" => "http://test.host/api/things/20/relationships/box", - "related" => "http://test.host/api/things/20/box" - }, - "data" => { - "type" => "boxes", - "id" => "100" - } - }, - "user" => { - "links" => { - "self" => "http://test.host/api/things/20/relationships/user", - "related" => "http://test.host/api/things/20/user" - }, - "data" => { - "type" => "users", - "id" => "10001" - } - }, - "things" => { - "links" => { - "self" => "http://test.host/api/things/20/relationships/things", - "related" => "http://test.host/api/things/20/things" - }, - "data" => [ - { - "type" => "things", - "id" => "10" - } - ] - } - } + "id" => "10" + } + ] + } + } + }, + { + "id" => "30", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/30" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/box", + "related" => "http://test.host/api/things/30/box" }, - { - "id" => "30", + "data" => { + "type" => "boxes", + "id" => "102" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/user", + "related" => "http://test.host/api/things/30/user" + }, + "data" => { + "type" => "users", + "id" => "10002" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/things", + "related" => "http://test.host/api/things/30/things" + }, + "data" => [ + { "type" => "things", - "links" => { - "self" => "http://test.host/api/things/30" - }, - "relationships" => { - "box" => { - "links" => { - "self" => "http://test.host/api/things/30/relationships/box", - "related" => "http://test.host/api/things/30/box" - }, - "data" => { - "type" => "boxes", - "id" => "102" - } - }, - "user" => { - "links" => { - "self" => "http://test.host/api/things/30/relationships/user", - "related" => "http://test.host/api/things/30/user" - }, - "data" => { - "type" => "users", - "id" => "10002" - } - - }, - "things" => { - "links" => { - "self" => "http://test.host/api/things/30/relationships/things", - "related" => "http://test.host/api/things/30/things" - }, - "data" => [ - { - "type" => "things", - "id" => "40" - }, - { - "type" => "things", - "id" => "50" - } - - ] - } - } + "id" => "40" + }, + { + "type" => "things", + "id" => "50" + } + ] + } + } + }, + { + "id" => "40", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/40" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/box", + "related" => "http://test.host/api/things/40/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/user", + "related" => "http://test.host/api/things/40/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/things", + "related" => "http://test.host/api/things/40/things" }, - { - "id" => "40", + "data" => [ + { "type" => "things", - "links" => { - "self" => "http://test.host/api/things/40" - }, - "relationships" => { - "box" => { - "links" => { - "self" => "http://test.host/api/things/40/relationships/box", - "related" => "http://test.host/api/things/40/box" - } - }, - "user" => { - "links" => { - "self" => "http://test.host/api/things/40/relationships/user", - "related" => "http://test.host/api/things/40/user" - } - }, - "things" => { - "links" => { - "self" => "http://test.host/api/things/40/relationships/things", - "related" => "http://test.host/api/things/40/things" - } - } - } + "id" => "30" + } + ] + } + } + }, + { + "id" => "50", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/50" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/box", + "related" => "http://test.host/api/things/50/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/user", + "related" => "http://test.host/api/things/50/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/things", + "related" => "http://test.host/api/things/50/things" }, - { - "id" => "50", + "data" => [ + { "type" => "things", - "links" => { - "self" => "http://test.host/api/things/50" - }, - "relationships" => { - "box" => { - "links" => { - "self" => "http://test.host/api/things/50/relationships/box", - "related" => "http://test.host/api/things/50/box" - } - }, - "user" => { - "links" => { - "self" => "http://test.host/api/things/50/relationships/user", - "related" => "http://test.host/api/things/50/user" - } - }, - "things" => { - "links" => { - "self" => "http://test.host/api/things/50/relationships/things", - "related" => "http://test.host/api/things/50/things" - } - } - } + "id" => "30" + } + ] + } + } + }, + { + "id" => "10001", + "type" => "users", + "links" => { + "self" => "http://test.host/api/users/10001" + }, + "attributes" => { + "name" => "user 1" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/users/10001/relationships/things", + "related" => "http://test.host/api/users/10001/things" }, - { - "id" => "10001", - "type" => "users", - "links" => { - "self" => "http://test.host/api/users/10001" - }, - "attributes" => { - "name" => "user 1" - }, - "relationships" => { - "things" => { - "links" => { - "self" => "http://test.host/api/users/10001/relationships/things", - "related" => "http://test.host/api/users/10001/things" - }, - "data" => [ - { - "type" => "things", - "id" => "10" - }, - { - "type" => "things", - "id" => "20" - } - ] - } - } + "data" => [ + { + "type" => "things", + "id" => "10" + }, + { + "type" => "things", + "id" => "20" + } + ] + } + } + }, + { + "id" => "10002", + "type" => "users", + "links" => { + "self" => "http://test.host/api/users/10002" + }, + "attributes" => { + "name" => "user 2" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/users/10002/relationships/things", + "related" => "http://test.host/api/users/10002/things" }, - { - "id" => "10002", - "type" => "users", - "links" => { - "self" => "http://test.host/api/users/10002" - }, - "attributes" => { - "name" => "user 2" - }, - "relationships" => { - "things" => { - "links" => { - "self" => "http://test.host/api/users/10002/relationships/things", - "related" => "http://test.host/api/users/10002/things" - }, - "data" => [ - { - "type" => "things", - "id" => "30" - } - ] - } - } - } - ] - } + "data" => [ + { + "type" => "things", + "id" => "30" + } + ] + } + } + } + ] + } assert_hash_equals(expected, sorted_json_response) end end class BlogPostsControllerTest < ActionController::TestCase def test_filter_by_delegated_attribute - assert_cacheable_get :index, params: {filter: {name: 'some title'}} + assert_cacheable_get :index, params: { filter: { name: 'some title' } } assert_response :success end def test_sorting_by_delegated_attribute - assert_cacheable_get :index, params: {sort: 'name'} + assert_cacheable_get :index, params: { sort: 'name' } assert_response :success end def test_fields_with_delegated_attribute - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :underscored_key + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :underscored_key - assert_cacheable_get :index, params: {fields: {blog_posts: 'name'}} - assert_response :success - assert_equal ['name'], json_response['data'].first['attributes'].keys - ensure - JSONAPI.configuration = original_config + assert_cacheable_get :index, params: { fields: { blog_posts: 'name' } } + assert_response :success + assert_equal ['name'], json_response['data'].first['attributes'].keys + end end end @@ -4827,14 +4870,14 @@ def test_fetch_robots_with_sort_by_name end Robot.create! name: 'John', version: 1 Robot.create! name: 'jane', version: 1 - assert_cacheable_get :index, params: {sort: 'name'} + assert_cacheable_get :index, params: { sort: 'name' } assert_response :success expected_names = Robot - .all - .order(name: :asc) - .map(&:name) - actual_names = json_response['data'].map {|data| + .all + .order(name: :asc) + .map(&:name) + actual_names = json_response['data'].map { |data| data['attributes']['name'] } assert_equal expected_names, actual_names, "since adapter_sorts_nulls_last=#{adapter_sorts_nulls_last}" @@ -4843,7 +4886,7 @@ def test_fetch_robots_with_sort_by_name def test_fetch_robots_with_sort_by_lower_name Robot.create! name: 'John', version: 1 Robot.create! name: 'jane', version: 1 - assert_cacheable_get :index, params: {sort: 'lower_name'} + assert_cacheable_get :index, params: { sort: 'lower_name' } assert_response :success assert_equal 'jane', json_response['data'].first['attributes']['name'] end @@ -4851,7 +4894,7 @@ def test_fetch_robots_with_sort_by_lower_name def test_fetch_robots_with_sort_by_version Robot.create! name: 'John', version: 1 Robot.create! name: 'jane', version: 2 - assert_cacheable_get :index, params: {sort: 'version'} + assert_cacheable_get :index, params: { sort: 'version' } assert_response 400 assert_equal 'version is not a valid sort criteria for robots', json_response['errors'].first['detail'] end @@ -4868,7 +4911,7 @@ def test_that_the_last_two_author_details_belong_to_an_author total_count = AuthorDetail.count assert_operator total_count, :>=, 2 - assert_cacheable_get :index, params: {sort: :id, include: :author, page: {limit: 10, offset: total_count - 2}} + assert_cacheable_get :index, params: { sort: :id, include: :author, page: { limit: 10, offset: total_count - 2 } } assert_response :success assert_equal 2, json_response['data'].size assert_not_nil json_response['data'][0]['relationships']['author']['data'] @@ -4881,7 +4924,7 @@ def test_that_the_last_author_detail_includes_its_author_even_if_returned_as_the total_count = AuthorDetail.count assert_operator total_count, :>=, 2 - assert_cacheable_get :index, params: {sort: :id, include: :author, page: {limit: 10, offset: total_count - 1}} + assert_cacheable_get :index, params: { sort: :id, include: :author, page: { limit: 10, offset: total_count - 1 } } assert_response :success assert_equal 1, json_response['data'].size assert_not_nil json_response['data'][0]['relationships']['author']['data'] diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index f89593178..2fc1ae7c4 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -467,6 +467,7 @@ class ResponseText < ActiveRecord::Base end class ResponseText::Paragraph < ResponseText + belongs_to :response end class Person < ActiveRecord::Base @@ -590,7 +591,7 @@ class Planet < ActiveRecord::Base def check_not_pluto # Pluto can't be a planet, so cancel the save - if name.downcase == 'pluto' + if name.underscore == 'pluto' throw(:abort) end end @@ -728,7 +729,7 @@ class Picture < ActiveRecord::Base belongs_to :document, -> { where( pictures: { imageable_type: 'Document' } ) }, foreign_key: 'imageable_id' belongs_to :product, -> { where( pictures: { imageable_type: 'Product' } ) }, foreign_key: 'imageable_id' - has_one :file_properties, as: 'fileable' + has_one :file_properties, as: :fileable end class Vehicle < ActiveRecord::Base @@ -744,13 +745,13 @@ class Boat < Vehicle class Document < ActiveRecord::Base has_many :pictures, as: :imageable belongs_to :author, class_name: 'Person', foreign_key: 'author_id' - has_one :file_properties, as: 'fileable' + has_one :file_properties, as: :fileable end class Product < ActiveRecord::Base has_many :pictures, as: :imageable belongs_to :designer, class_name: 'Person', foreign_key: 'designer_id' - has_one :file_properties, as: 'fileable' + has_one :file_properties, as: :fileable end class FileProperties < ActiveRecord::Base @@ -1252,7 +1253,9 @@ def responses=params } } end - def responses + + def responses(options) + [] end def self.creatable_fields(context) @@ -1524,7 +1527,7 @@ class EmployeeResource < JSONAPI::Resource has_many :expense_entries end -class PoroResource < JSONAPI::BasicResource +class PoroResource < JSONAPI::SimpleResource root_resource class << self @@ -1637,7 +1640,6 @@ def find_by_keys(keys, options = {}) end class BreedResource < PoroResource - attribute :name, format: :title # This is unneeded, just here for testing @@ -1710,7 +1712,9 @@ class CraterResource < JSONAPI::Resource filter :description, apply: -> (records, value, options) { fail "context not set" unless options[:context][:current_user] != nil && options[:context][:current_user] == $test_user - records.where(concat_table_field(options.dig(:_relation_helper_options, :join_manager).source_join_details[:alias], :description) => value) + join_manager = options.dig(:_relation_helper_options, :join_manager) + field = join_manager ? get_aliased_field('description', join_manager) : 'description' + records.where(Arel.sql(field) => value) } def self.verify_key(key, context = nil) @@ -1751,7 +1755,11 @@ class PictureResource < JSONAPI::Resource has_one :author has_one :imageable, polymorphic: true - has_one :file_properties, inverse_relationship: :fileable, :foreign_key_on => :related, polymorphic: true + # the imageable polymorphic relationship will implicitly create the following relationships + # has_one :document, exclude_linkage_data: true, polymorphic_type_relationship_for: :imageable + # has_one :product, exclude_linkage_data: true, polymorphic_type_relationship_for: :imageable + + has_one :file_properties, :foreign_key_on => :related filter 'imageable.name', perform_joins: true, apply: -> (records, value, options) { join_manager = options.dig(:_relation_helper_options, :join_manager) @@ -1768,6 +1776,7 @@ class PictureResource < JSONAPI::Resource class ImageableResource < JSONAPI::Resource polymorphic + has_one :picture end class FileableResource < JSONAPI::Resource @@ -1776,7 +1785,10 @@ class FileableResource < JSONAPI::Resource class DocumentResource < JSONAPI::Resource attribute :name - has_many :pictures, inverse_relationship: :imageable + + # Will use implicitly defined inverse relationship on PictureResource + has_many :pictures + has_one :author, class_name: 'Person' has_one :file_properties, inverse_relationship: :fileable, :foreign_key_on => :related @@ -1784,7 +1796,9 @@ class DocumentResource < JSONAPI::Resource class ProductResource < JSONAPI::Resource attribute :name - has_many :pictures, inverse_relationship: :imageable + + # Will use implicitly defined inverse relationship on PictureResource + has_many :pictures has_one :designer, class_name: 'Person' has_one :file_properties, inverse_relationship: :fileable, :foreign_key_on => :related @@ -2204,8 +2218,6 @@ class CommentResource < CommentResource; end class PostResource < PostResource attribute :base - has_one :author - def base _model.title end @@ -2355,7 +2367,7 @@ class PreferencesResource < JSONAPI::Resource key } - has_one :person, :foreign_key_on => :related + has_one :person, foreign_key_on: :related, relation_name: :author attribute :nickname end diff --git a/test/helpers/assertions.rb b/test/helpers/assertions.rb index 0d100f985..42b282345 100644 --- a/test/helpers/assertions.rb +++ b/test/helpers/assertions.rb @@ -1,7 +1,7 @@ module Helpers module Assertions def assert_hash_equals(exp, act, msg = nil) - msg = message(msg, '') { diff exp, act } + msg = message(msg, '') { diff exp.deep_stringify_keys, act.deep_stringify_keys } assert(matches_hash?(exp, act, {exact: true}), msg) end diff --git a/test/helpers/configuration_helpers.rb b/test/helpers/configuration_helpers.rb index b3f14f443..fd40f2f04 100644 --- a/test/helpers/configuration_helpers.rb +++ b/test/helpers/configuration_helpers.rb @@ -1,5 +1,14 @@ module Helpers module ConfigurationHelpers + def with_jsonapi_config_changes(&block) + orig_config = JSONAPI.configuration.dup + yield + ensure + $PostProcessorRaisesErrors = false + $PostSerializerRaisesErrors = false + JSONAPI.configuration = orig_config + end + def with_jsonapi_config(new_config_options) original_config = JSONAPI.configuration.dup # TODO should be a deep dup begin @@ -29,7 +38,7 @@ def with_resource_caching(cache, classes = :all) with_jsonapi_config(new_config_options) do if classes == :all or (classes.is_a?(Hash) && classes.keys == [:except]) resource_classes = ObjectSpace.each_object(Class).select do |klass| - if klass < JSONAPI::BasicResource + if klass < JSONAPI::Resource # Not using Resource#_model_class to avoid tripping the warning early, which could # cause ResourceTest#test_nil_model_class to fail. model_class = klass._model_name.to_s.safe_constantize diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 4ab186021..3eee0f97b 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -31,6 +31,8 @@ def test_get_not_found end def test_post_sessions + skip "This test isn't compatible with v09" if testing_v09? + session_id = SecureRandom.uuid post '/sessions', params: { @@ -1485,7 +1487,18 @@ def test_include_parameter_openquoted end def test_getting_different_resources_when_sti - assert_cacheable_jsonapi_get '/vehicles' + get '/vehicles' + assert_jsonapi_response 200 + types = json_response['data'].map{|r| r['type']}.to_set + assert types == Set['cars', 'boats'] + + # Testing the cached get separately since find_to_populate_by_keys does not use sorting resulting in + # unsorted results with STI + cache = ActiveSupport::Cache::MemoryStore.new + with_resource_caching(cache) do + get '/vehicles' + end + assert_jsonapi_response 200 types = json_response['data'].map{|r| r['type']}.to_set assert types == Set['cars', 'boats'] end @@ -1557,6 +1570,10 @@ def test_get_resource_include_singleton_relationship "links" => { "self" => "http://www.example.com/api/v9/preferences/relationships/person", "related" => "http://www.example.com/api/v9/preferences/person" + }, + 'data' => { + 'type' => 'people', + 'id' => '1005' } } }, @@ -1616,6 +1633,10 @@ def test_caching_included_singleton "links" => { "self" => "http://www.example.com/api/v9/preferences/relationships/person", "related" => "http://www.example.com/api/v9/preferences/person" + }, + "data" => { + "type" => "people", + "id" => "1005" } } }, @@ -1664,6 +1685,10 @@ def test_caching_included_singleton "links" => { "self" => "http://www.example.com/api/v9/preferences/relationships/person", "related" => "http://www.example.com/api/v9/preferences/person" + }, + "data" => { + "type" => "people", + "id" => "1001" } } }, diff --git a/test/test_helper.rb b/test/test_helper.rb index f90a119db..c43446c0c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -164,7 +164,7 @@ def assign_parameters(routes, controller_path, action, parameters, generated_pat def assert_query_count(expected, msg = nil, &block) @queries = [] callback = lambda {|_, _, _, _, payload| - @queries.push payload[:sql] + @queries.push payload[:sql] unless payload[:sql].starts_with?("SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence'") } ActiveSupport::Notifications.subscribed(callback, 'sql.active_record', &block) @@ -505,6 +505,10 @@ def db_true def sql_for_compare(sql) sql.tr(db_quote_identifier, %{"}) end + + def response_json_for_compare(response) + response.pretty_inspect + end end class ActiveSupport::TestCase @@ -547,8 +551,8 @@ def assert_cacheable_jsonapi_get(url, cached_classes = :all) end assert_equal( - sql_for_compare(non_caching_response.pretty_inspect), - sql_for_compare(json_response.pretty_inspect), + response_json_for_compare(non_caching_response), + response_json_for_compare(json_response), "Cache warmup response must match normal response" ) @@ -557,13 +561,18 @@ def assert_cacheable_jsonapi_get(url, cached_classes = :all) end assert_equal( - sql_for_compare(non_caching_response.pretty_inspect), - sql_for_compare(json_response.pretty_inspect), + response_json_for_compare(non_caching_response), + response_json_for_compare(json_response), "Cached response must match normal response" ) assert_equal 0, cached[:total][:misses], "Cached response must not cause any cache misses" assert_equal warmup[:total][:misses], cached[:total][:hits], "Cached response must use cache" end + + + def testing_v09? + JSONAPI.configuration.default_resource_retrieval_strategy == 'JSONAPI::ActiveRelationRetrievalV09' + end end class ActionController::TestCase @@ -626,16 +635,18 @@ def assert_cacheable_get(action, **args) "Cache (mode: #{mode}) #{phase} response status must match normal response" ) assert_equal( - sql_for_compare(non_caching_response.pretty_inspect), - sql_for_compare(json_response_sans_all_backtraces.pretty_inspect), - "Cache (mode: #{mode}) #{phase} response body must match normal response" - ) - assert_operator( - cache_queries.size, - :<=, - normal_queries.size, - "Cache (mode: #{mode}) #{phase} action made too many queries:\n#{cache_queries.pretty_inspect}" + response_json_for_compare(non_caching_response), + response_json_for_compare(json_response_sans_all_backtraces), + "Cache (mode: #{mode}) #{phase} response body must match normal response\n#{non_caching_response.pretty_inspect},\n#{json_response_sans_all_backtraces.pretty_inspect}" ) + + # The query count will now differ between the cached and non cached versions so we will not test that + # assert_operator( + # cache_queries.size, + # :<=, + # normal_queries.size, + # "Cache (mode: #{mode}) #{phase} action made too many queries:\n#{cache_queries.pretty_inspect}" + # ) end if mode == :all @@ -664,6 +675,14 @@ def assert_cacheable_get(action, **args) @queries = orig_queries end + def testing_v10? + JSONAPI.configuration.default_resource_retrieval_strategy == 'JSONAPI::ActiveRelationRetrievalV10' + end + + def testing_v09? + JSONAPI.configuration.default_resource_retrieval_strategy == 'JSONAPI::ActiveRelationRetrievalV09' + end + private def json_response_sans_all_backtraces @@ -728,7 +747,7 @@ def format(raw_value) end def unformat(value) - value.to_s.downcase + value.to_s.underscore end end end diff --git a/test/unit/active_relation_resource_finder/join_manager_test.rb b/test/unit/active_relation_resource_finder/join_manager_test.rb index 43387f38b..5b46d3450 100644 --- a/test/unit/active_relation_resource_finder/join_manager_test.rb +++ b/test/unit/active_relation_resource_finder/join_manager_test.rb @@ -1,10 +1,17 @@ require File.expand_path('../../../test_helper', __FILE__) require 'jsonapi-resources' -class JoinTreeTest < ActiveSupport::TestCase +class JoinManagerTest < ActiveSupport::TestCase + # def setup + # JSONAPI.configuration.default_alias_on_join = false + # end + # + # def teardown + # JSONAPI.configuration.default_alias_on_join = false + # end def test_no_added_joins - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource) + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource) records = PostResource.records({}) records = join_manager.join(records, {}) @@ -15,7 +22,7 @@ def test_no_added_joins def test_add_single_join filters = {'tags' => ['1']} - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, filters: filters) records = PostResource.records({}) records = join_manager.join(records, {}) assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) @@ -25,7 +32,7 @@ def test_add_single_join def test_add_single_sort_join sort_criteria = [{field: 'tags.name', direction: :desc}] - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, sort_criteria: sort_criteria) + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, sort_criteria: sort_criteria) records = PostResource.records({}) records = join_manager.join(records, {}) @@ -37,7 +44,7 @@ def test_add_single_sort_join def test_add_single_sort_and_filter_join filters = {'tags' => ['1']} sort_criteria = [{field: 'tags.name', direction: :desc}] - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, sort_criteria: sort_criteria, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, sort_criteria: sort_criteria, filters: filters) records = PostResource.records({}) records = join_manager.join(records, {}) assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) @@ -51,7 +58,7 @@ def test_add_sibling_joins 'author' => ['1'] } - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, filters: filters) records = PostResource.records({}) records = join_manager.join(records, {}) @@ -63,7 +70,7 @@ def test_add_sibling_joins def test_add_joins_source_relationship - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, source_relationship: PostResource._relationship(:comments)) records = PostResource.records({}) records = join_manager.join(records, {}) @@ -74,7 +81,7 @@ def test_add_joins_source_relationship def test_add_joins_source_relationship_with_custom_apply - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, source_relationship: Api::V10::PostResource._relationship(:comments)) records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) @@ -93,7 +100,7 @@ def test_add_nested_scoped_joins 'author' => ['1'] } - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, filters: filters) records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) @@ -110,7 +117,7 @@ def test_add_nested_scoped_joins 'comments.tags' => ['1'] } - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, filters: filters) records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) @@ -128,7 +135,7 @@ def test_add_nested_joins_with_fields 'author.foo' => ['1'] } - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, filters: filters) records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) @@ -142,7 +149,7 @@ def test_add_nested_joins_with_fields def test_add_joins_with_sub_relationship relationships = %w(author author.comments tags) - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, relationships: relationships, + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, relationships: relationships, source_relationship: Api::V10::PostResource._relationship(:comments)) records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) @@ -161,7 +168,7 @@ def test_add_joins_with_sub_relationship_and_filters relationships = %w(author author.comments tags) - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, filters: filters, relationships: relationships, source_relationship: PostResource._relationship(:comments)) @@ -176,8 +183,10 @@ def test_add_joins_with_sub_relationship_and_filters end def test_polymorphic_join_belongs_to_just_source - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PictureResource, - source_relationship: PictureResource._relationship(:imageable)) + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new( + resource_klass: PictureResource, + source_relationship: PictureResource._relationship(:imageable) + ) records = PictureResource.records({}) records = join_manager.join(records, {}) @@ -191,7 +200,7 @@ def test_polymorphic_join_belongs_to_just_source def test_polymorphic_join_belongs_to_filter filters = {'imageable' => ['Foo']} - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PictureResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PictureResource, filters: filters) records = PictureResource.records({}) records = join_manager.join(records, {}) @@ -208,12 +217,12 @@ def test_polymorphic_join_belongs_to_filter_on_resource } relationships = %w(imageable file_properties) - join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PictureResource, + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PictureResource, filters: filters, relationships: relationships) records = PictureResource.records({}) - records = join_manager.join(records, {}) + join_manager.join(records, {}) assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details) assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) diff --git a/test/unit/active_relation_resource_finder/join_manager_v10_test.rb b/test/unit/active_relation_resource_finder/join_manager_v10_test.rb new file mode 100644 index 000000000..bae45ecda --- /dev/null +++ b/test/unit/active_relation_resource_finder/join_manager_v10_test.rb @@ -0,0 +1,222 @@ +require File.expand_path('../../../test_helper', __FILE__) +require 'jsonapi-resources' + +class JoinManagerV10Test < ActiveSupport::TestCase + def test_no_added_joins + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource) + + records = PostResource.records({}) + records = join_manager.join(records, {}) + assert_equal 'SELECT "posts".* FROM "posts"', sql_for_compare(records.to_sql) + + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + end + + def test_add_single_join + filters = {'tags' => ['1']} + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, filters: filters) + records = PostResource.records({}) + records = join_manager.join(records, {}) + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + end + + def test_add_single_sort_join + sort_criteria = [{field: 'tags.name', direction: :desc}] + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, sort_criteria: sort_criteria) + records = PostResource.records({}) + records = join_manager.join(records, {}) + + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + end + + def test_add_single_sort_and_filter_join + filters = {'tags' => ['1']} + sort_criteria = [{field: 'tags.name', direction: :desc}] + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, sort_criteria: sort_criteria, filters: filters) + records = PostResource.records({}) + records = join_manager.join(records, {}) + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + end + + def test_add_sibling_joins + filters = { + 'tags' => ['1'], + 'author' => ['1'] + } + + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, filters: filters) + records = PostResource.records({}) + records = join_manager.join(records, {}) + + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id"', sql_for_compare(records.to_sql) + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:author))) + end + + + def test_add_joins_source_relationship + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, + source_relationship: PostResource._relationship(:comments)) + records = PostResource.records({}) + records = join_manager.join(records, {}) + + assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id"', sql_for_compare(records.to_sql) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + end + + + def test_add_joins_source_relationship_with_custom_apply + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, + source_relationship: Api::V10::PostResource._relationship(:comments)) + records = Api::V10::PostResource.records({}) + records = join_manager.join(records, {}) + + sql = 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."approved" = ' + db_true + + assert_equal sql, sql_for_compare(records.to_sql) + + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + end + + def test_add_nested_scoped_joins + filters = { + 'comments.author' => ['1'], + 'comments.tags' => ['1'], + 'author' => ['1'] + } + + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, filters: filters) + records = Api::V10::PostResource.records({}) + records = join_manager.join(records, {}) + + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author))) + + # Now test with different order for the filters + filters = { + 'author' => ['1'], + 'comments.author' => ['1'], + 'comments.tags' => ['1'] + } + + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, filters: filters) + records = Api::V10::PostResource.records({}) + records = join_manager.join(records, {}) + + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author))) + end + + def test_add_nested_joins_with_fields + filters = { + 'comments.author.name' => ['1'], + 'comments.tags.id' => ['1'], + 'author.foo' => ['1'] + } + + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, filters: filters) + records = Api::V10::PostResource.records({}) + records = join_manager.join(records, {}) + + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author))) + end + + def test_add_joins_with_sub_relationship + relationships = %w(author author.comments tags) + + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, relationships: relationships, + source_relationship: Api::V10::PostResource._relationship(:comments)) + records = Api::V10::PostResource.records({}) + records = join_manager.join(records, {}) + + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PersonResource._relationship(:comments))) + end + + def test_add_joins_with_sub_relationship_and_filters + filters = { + 'author.name' => ['1'], + 'author.comments.name' => ['Foo'] + } + + relationships = %w(author author.comments tags) + + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, + filters: filters, + relationships: relationships, + source_relationship: PostResource._relationship(:comments)) + records = PostResource.records({}) + records = join_manager.join(records, {}) + + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(PostResource._relationship(:comments))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(PersonResource._relationship(:comments))) + end + + def test_polymorphic_join_belongs_to_just_source + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PictureResource, + source_relationship: PictureResource._relationship(:imageable)) + + records = PictureResource.records({}) + records = join_manager.join(records, {}) + + # assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\'', sql_for_compare(records.to_sql) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.source_join_details('products')) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.source_join_details('documents')) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) + end + + def test_polymorphic_join_belongs_to_filter + filters = {'imageable' => ['Foo']} + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PictureResource, filters: filters) + + records = PictureResource.records({}) + records = join_manager.join(records, {}) + + # assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\'', sql_for_compare(records.to_sql) + assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) + end + + def test_polymorphic_join_belongs_to_filter_on_resource + filters = { + 'imageable#documents.name' => ['foo'] + } + + relationships = %w(imageable file_properties) + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PictureResource, + filters: filters, + relationships: relationships) + + records = PictureResource.records({}) + records = join_manager.join(records, {}) + + assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) + assert_hash_equals({alias: 'file_properties', join_type: :left}, join_manager.join_details_by_relationship(PictureResource._relationship(:file_properties))) + end +end diff --git a/test/unit/resource/active_relation_resource_test.rb b/test/unit/resource/active_relation_resource_test.rb deleted file mode 100644 index 858009c9b..000000000 --- a/test/unit/resource/active_relation_resource_test.rb +++ /dev/null @@ -1,237 +0,0 @@ -require File.expand_path('../../../test_helper', __FILE__) - -class ArPostResource < JSONAPI::Resource - model_name 'Post' - attribute :headline, delegate: :title - has_one :author - has_many :tags, primary_key: :tags_import_id -end - -class ActiveRelationResourceTest < ActiveSupport::TestCase - def setup - end - - def test_find_fragments_no_attributes - filters = {} - posts_identities = ArPostResource.find_fragments(filters) - - assert_equal 20, posts_identities.length - assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.values[0].identity - assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) - end - - def test_find_fragments_cache_field - filters = {} - options = { cache: true } - posts_identities = ArPostResource.find_fragments(filters, options) - - assert_equal 20, posts_identities.length - assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.values[0].identity - assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) - assert posts_identities.values[0].cache.is_a?(ActiveSupport::TimeWithZone) - end - - def test_find_fragments_cache_field_attributes - filters = {} - options = { attributes: [:headline, :author_id], cache: true } - posts_identities = ArPostResource.find_fragments(filters, options) - - assert_equal 20, posts_identities.length - assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.values[0].identity - assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) - assert_equal 2, posts_identities.values[0].attributes.length - assert posts_identities.values[0].cache.is_a?(ActiveSupport::TimeWithZone) - assert_equal 'New post', posts_identities.values[0].attributes[:headline] - assert_equal 1001, posts_identities.values[0].attributes[:author_id] - end - - def test_find_related_has_one_fragments_no_attributes - options = {} - source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), - JSONAPI::ResourceIdentity.new(ArPostResource, 2), - JSONAPI::ResourceIdentity.new(ArPostResource, 20)] - source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - - related_fragments = ArPostResource.find_included_fragments(source_fragments, 'author', options) - - assert_equal 2, related_fragments.length - assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.values[0].identity - assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) - assert_equal 2, related_fragments.values[0].related_from.length - end - - def test_find_related_has_one_fragments_cache_field - options = { cache: true } - source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), - JSONAPI::ResourceIdentity.new(ArPostResource, 2), - JSONAPI::ResourceIdentity.new(ArPostResource, 20)] - source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - - related_fragments = ArPostResource.find_included_fragments(source_fragments, 'author', options) - - assert_equal 2, related_fragments.length - assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.values[0].identity - assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) - assert_equal 2, related_fragments.values[0].related_from.length - assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) - end - - def test_find_related_has_one_fragments_cache_field_attributes - options = { cache: true, attributes: [:name] } - source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), - JSONAPI::ResourceIdentity.new(ArPostResource, 2), - JSONAPI::ResourceIdentity.new(ArPostResource, 20)] - source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - - related_fragments = ArPostResource.find_included_fragments(source_fragments, 'author', options) - - assert_equal 2, related_fragments.length - assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.values[0].identity - assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) - assert_equal 2, related_fragments.values[0].related_from.length - assert_equal 1, related_fragments.values[0].attributes.length - assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) - assert_equal 'Joe Author', related_fragments.values[0].attributes[:name] - end - - def test_find_related_has_many_fragments_no_attributes - options = {} - source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), - JSONAPI::ResourceIdentity.new(ArPostResource, 2), - JSONAPI::ResourceIdentity.new(ArPostResource, 12), - JSONAPI::ResourceIdentity.new(ArPostResource, 14)] - source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - - related_fragments = ArPostResource.find_included_fragments(source_fragments, 'tags', options) - - assert_equal 8, related_fragments.length - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.values[0].identity - assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) - assert_equal 1, related_fragments.values[0].related_from.length - assert_equal 2, related_fragments[JSONAPI::ResourceIdentity.new(TagResource, 502)].related_from.length - end - - def test_find_related_has_many_fragments_pagination - params = ActionController::Parameters.new(number: 2, size: 4) - options = { paginator: PagedPaginator.new(params) } - source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 15)] - source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - - related_fragments = ArPostResource.find_included_fragments(source_fragments, 'tags', options) - - assert_equal 1, related_fragments.length - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 516), related_fragments.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 516), related_fragments.values[0].identity - assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) - assert_equal 1, related_fragments.values[0].related_from.length - end - - def test_find_related_has_many_fragments_cache_field - options = { cache: true } - source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), - JSONAPI::ResourceIdentity.new(ArPostResource, 2), - JSONAPI::ResourceIdentity.new(ArPostResource, 12), - JSONAPI::ResourceIdentity.new(ArPostResource, 14)] - source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - - related_fragments = ArPostResource.find_included_fragments(source_fragments, 'tags', options) - - assert_equal 8, related_fragments.length - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.values[0].identity - assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) - assert_equal 1, related_fragments.values[0].related_from.length - assert_equal 2, related_fragments[JSONAPI::ResourceIdentity.new(TagResource, 502)].related_from.length - assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) - end - - def test_find_related_has_many_fragments_cache_field_attributes - options = { cache: true, attributes: [:name] } - source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), - JSONAPI::ResourceIdentity.new(ArPostResource, 2), - JSONAPI::ResourceIdentity.new(ArPostResource, 12), - JSONAPI::ResourceIdentity.new(ArPostResource, 14)] - - source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - related_fragments = ArPostResource.find_included_fragments(source_fragments, 'tags', options) - - assert_equal 8, related_fragments.length - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.values[0].identity - assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) - assert_equal 1, related_fragments.values[0].related_from.length - assert_equal 2, related_fragments[JSONAPI::ResourceIdentity.new(TagResource, 502)].related_from.length - assert_equal 1, related_fragments.values[0].attributes.length - assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) - assert_equal 'short', related_fragments.values[0].attributes[:name] - end - - def test_find_related_polymorphic_fragments_no_attributes - options = {} - source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), - JSONAPI::ResourceIdentity.new(PictureResource, 2), - JSONAPI::ResourceIdentity.new(PictureResource, 3)] - source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - - related_fragments = PictureResource.find_included_fragments(source_fragments, 'imageable', options) - - assert_equal 2, related_fragments.length - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.values[0].identity - assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.keys[1] - assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.values[1].identity - assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) - assert_equal 1, related_fragments.values[0].related_from.length - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.values[0].identity - end - - def test_find_related_polymorphic_fragments_cache_field - options = { cache: true } - source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), - JSONAPI::ResourceIdentity.new(PictureResource, 2), - JSONAPI::ResourceIdentity.new(PictureResource, 3)] - source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - - related_fragments = PictureResource.find_included_fragments(source_fragments, 'imageable', options) - - assert_equal 2, related_fragments.length - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.values[0].identity - assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.keys[1] - assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.values[1].identity - assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) - assert_equal 1, related_fragments.values[0].related_from.length - assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) - assert related_fragments.values[1].cache.is_a?(ActiveSupport::TimeWithZone) - end - - def test_find_related_polymorphic_fragments_cache_field_attributes - options = { cache: true, attributes: [:name] } - source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), - JSONAPI::ResourceIdentity.new(PictureResource, 2), - JSONAPI::ResourceIdentity.new(PictureResource, 3)] - source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } - - related_fragments = PictureResource.find_included_fragments(source_fragments, 'imageable', options) - - assert_equal 2, related_fragments.length - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.keys[0] - assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.values[0].identity - assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.keys[1] - assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.values[1].identity - assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) - assert_equal 1, related_fragments.values[0].related_from.length - assert_equal 1, related_fragments.values[0].attributes.length - assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) - assert related_fragments.values[1].cache.is_a?(ActiveSupport::TimeWithZone) - assert_equal 'Enterprise Gizmo', related_fragments.values[0].attributes[:name] - assert_equal 'Company Brochure', related_fragments.values[1].attributes[:name] - end -end diff --git a/test/unit/resource/active_relation_resource_v_10_test.rb b/test/unit/resource/active_relation_resource_v_10_test.rb new file mode 100644 index 000000000..e4f729580 --- /dev/null +++ b/test/unit/resource/active_relation_resource_v_10_test.rb @@ -0,0 +1,236 @@ +require File.expand_path('../../../test_helper', __FILE__) + +module V10 + class BaseResource + include JSONAPI::ResourceCommon + resource_retrieval_strategy 'JSONAPI::ActiveRelationRetrievalV10' + abstract + end + + class PostResource < V10::BaseResource + attribute :headline, delegate: :title + has_one :author + has_many :tags + end + + class AuthorResource < V10::BaseResource + model_name 'Person' + attributes :name + + has_many :posts, inverse_relationship: :author + has_many :pictures + end + + class TagResource < V10::BaseResource + attributes :name + + has_many :posts + end + + class PictureResource < V10::BaseResource + attribute :name + has_one :author + + has_one :imageable, polymorphic: true + end + + class ImageableResource < V10::BaseResource + polymorphic + has_one :picture + end + + class DocumentResource < V10::BaseResource + attribute :name + + has_many :pictures + + has_one :author, class_name: 'Person' + end + + class ProductResource < V10::BaseResource + attribute :name + has_many :pictures + has_one :designer, class_name: 'Person' + + has_one :file_properties, :foreign_key_on => :related + + def picture_id + _model.picture.id + end + end +end + +class ActiveRelationResourceTest < ActiveSupport::TestCase + def setup + # skip("Skipping: Currently test is only valid for ActiveRelationRetrievalV10") + end + + def test_find_fragments_no_attributes + filters = {} + posts_identities = V10::PostResource.find_fragments(filters) + + assert_equal 20, posts_identities.length + assert_equal JSONAPI::ResourceIdentity.new(V10::PostResource, 1), posts_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V10::PostResource, 1), posts_identities.values[0].identity + assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) + end + + def test_find_fragments_cache_field + filters = {} + options = { cache: true } + posts_identities = V10::PostResource.find_fragments(filters, options) + + assert_equal 20, posts_identities.length + assert_equal JSONAPI::ResourceIdentity.new(V10::PostResource, 1), posts_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V10::PostResource, 1), posts_identities.values[0].identity + assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) + assert posts_identities.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_has_one_fragments + options = {} + source_rids = [JSONAPI::ResourceIdentity.new(V10::PostResource, 1), + JSONAPI::ResourceIdentity.new(V10::PostResource, 2), + JSONAPI::ResourceIdentity.new(V10::PostResource, 20)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V10::PostResource._relationship('author') + related_fragments = V10::PostResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(V10::AuthorResource, 1001), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V10::AuthorResource, 1001), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 2, related_fragments.values[0].related_from.length + end + + def test_find_related_has_one_fragments_cache_field + options = { cache: true } + source_rids = [JSONAPI::ResourceIdentity.new(V10::PostResource, 1), + JSONAPI::ResourceIdentity.new(V10::PostResource, 2), + JSONAPI::ResourceIdentity.new(V10::PostResource, 20)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V10::PostResource._relationship('author') + related_fragments = V10::PostResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(V10::AuthorResource, 1001), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V10::AuthorResource, 1001), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 2, related_fragments.values[0].related_from.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_has_many_fragments + options = {} + source_rids = [JSONAPI::ResourceIdentity.new(V10::PostResource, 1), + JSONAPI::ResourceIdentity.new(V10::PostResource, 2), + JSONAPI::ResourceIdentity.new(V10::PostResource, 12), + JSONAPI::ResourceIdentity.new(V10::PostResource, 14)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V10::PostResource._relationship('tags') + related_fragments = V10::PostResource.send(:find_included_fragments, source_fragments, relationship, options) + + assert_equal 8, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(V10::TagResource, 501), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V10::TagResource, 501), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal 2, related_fragments[JSONAPI::ResourceIdentity.new(V10::TagResource, 502)].related_from.length + end + + def test_find_related_has_many_fragments_pagination + params = ActionController::Parameters.new(number: 2, size: 4) + options = { paginator: PagedPaginator.new(params) } + source_rids = [JSONAPI::ResourceIdentity.new(V10::PostResource, 15)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V10::PostResource._relationship('tags') + related_fragments = V10::PostResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 1, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(V10::TagResource, 516), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V10::TagResource, 516), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + end + + def test_find_related_has_many_fragments_cache_field + options = { cache: true } + source_rids = [JSONAPI::ResourceIdentity.new(V10::PostResource, 1), + JSONAPI::ResourceIdentity.new(V10::PostResource, 2), + JSONAPI::ResourceIdentity.new(V10::PostResource, 12), + JSONAPI::ResourceIdentity.new(V10::PostResource, 14)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V10::PostResource._relationship('tags') + related_fragments = V10::PostResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 8, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(V10::TagResource, 501), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V10::TagResource, 501), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal 2, related_fragments[JSONAPI::ResourceIdentity.new(V10::TagResource, 502)].related_from.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_polymorphic_fragments + options = {} + source_rids = [JSONAPI::ResourceIdentity.new(V10::PictureResource, 1), + JSONAPI::ResourceIdentity.new(V10::PictureResource, 2), + JSONAPI::ResourceIdentity.new(V10::PictureResource, 3)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V10::PictureResource._relationship('imageable') + related_fragments = V10::PictureResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 2, related_fragments.length + assert related_fragments.keys.include?(JSONAPI::ResourceIdentity.new(V10::ProductResource, 1)) + assert related_fragments.keys.include?(JSONAPI::ResourceIdentity.new(V10::DocumentResource, 1)) + + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal JSONAPI::ResourceIdentity.new(V10::ProductResource, 1), related_fragments.values[0].identity + end + + def test_find_related_polymorphic_fragments_cache_field + options = { cache: true } + source_rids = [JSONAPI::ResourceIdentity.new(V10::PictureResource, 1), + JSONAPI::ResourceIdentity.new(V10::PictureResource, 2), + JSONAPI::ResourceIdentity.new(V10::PictureResource, 3)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V10::PictureResource._relationship('imageable') + related_fragments = V10::PictureResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 2, related_fragments.length + assert related_fragments.keys.include?(JSONAPI::ResourceIdentity.new(V10::ProductResource, 1)) + assert related_fragments.keys.include?(JSONAPI::ResourceIdentity.new(V10::DocumentResource, 1)) + + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + assert related_fragments.values[1].cache.is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_polymorphic_fragments_not_cached + options = { cache: false } + source_rids = [JSONAPI::ResourceIdentity.new(V10::PictureResource, 1), + JSONAPI::ResourceIdentity.new(V10::PictureResource, 2), + JSONAPI::ResourceIdentity.new(V10::PictureResource, 3)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V10::PictureResource._relationship('imageable') + related_fragments = V10::PictureResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 2, related_fragments.length + assert related_fragments.keys.include?(JSONAPI::ResourceIdentity.new(V10::ProductResource, 1)) + assert related_fragments.keys.include?(JSONAPI::ResourceIdentity.new(V10::DocumentResource, 1)) + + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + end +end diff --git a/test/unit/resource/active_relation_resource_v_11_test.rb b/test/unit/resource/active_relation_resource_v_11_test.rb new file mode 100644 index 000000000..f9ae49b65 --- /dev/null +++ b/test/unit/resource/active_relation_resource_v_11_test.rb @@ -0,0 +1,238 @@ +require File.expand_path('../../../test_helper', __FILE__) + +module V11 + class BaseResource + include JSONAPI::ResourceCommon + resource_retrieval_strategy 'JSONAPI::ActiveRelationRetrieval' + abstract + end + + class PostResource < V11::BaseResource + model_name 'Post' + attribute :headline, delegate: :title + has_one :author + has_many :tags + end + + class AuthorResource < V11::BaseResource + model_name 'Person' + attributes :name + + has_many :posts, inverse_relationship: :author + has_many :pictures + end + + class TagResource < V11::BaseResource + attributes :name + + has_many :posts + end + + class PictureResource < V11::BaseResource + attribute :name + has_one :author + + has_one :imageable, polymorphic: true + end + + class ImageableResource < V11::BaseResource + polymorphic + has_one :picture + end + + class DocumentResource < V11::BaseResource + attribute :name + + has_many :pictures + + has_one :author, class_name: 'Person' + end + + class ProductResource < V11::BaseResource + attribute :name + has_many :pictures + has_one :designer, class_name: 'Person' + + has_one :file_properties, :foreign_key_on => :related + + def picture_id + _model.picture.id + end + end +end + +class ActiveRelationResourceTest < ActiveSupport::TestCase + def setup + # skip("Skipping: Currently test is only valid for ActiveRelationRetrievalV11") + end + + def test_find_fragments_no_attributes + filters = {} + posts_identities = V11::PostResource.find_fragments(filters) + + assert_equal 20, posts_identities.length + assert_equal JSONAPI::ResourceIdentity.new(V11::PostResource, 1), posts_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V11::PostResource, 1), posts_identities.values[0].identity + assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) + end + + def test_find_fragments_cache_field + filters = {} + options = { cache: true } + posts_identities = V11::PostResource.find_fragments(filters, options) + + assert_equal 20, posts_identities.length + assert_equal JSONAPI::ResourceIdentity.new(V11::PostResource, 1), posts_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V11::PostResource, 1), posts_identities.values[0].identity + assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) + assert posts_identities.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_has_one_fragments + options = {} + source_rids = [JSONAPI::ResourceIdentity.new(V11::PostResource, 1), + JSONAPI::ResourceIdentity.new(V11::PostResource, 2), + JSONAPI::ResourceIdentity.new(V11::PostResource, 20)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V11::PostResource._relationship('author') + related_fragments = V11::PostResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(V11::AuthorResource, 1001), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V11::AuthorResource, 1001), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 2, related_fragments.values[0].related_from.length + end + + def test_find_related_has_one_fragments_cache_field + options = { cache: true } + source_rids = [JSONAPI::ResourceIdentity.new(V11::PostResource, 1), + JSONAPI::ResourceIdentity.new(V11::PostResource, 2), + JSONAPI::ResourceIdentity.new(V11::PostResource, 20)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V11::PostResource._relationship('author') + related_fragments = V11::PostResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(V11::AuthorResource, 1001), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V11::AuthorResource, 1001), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 2, related_fragments.values[0].related_from.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_has_many_fragments + options = {} + source_rids = [JSONAPI::ResourceIdentity.new(V11::PostResource, 1), + JSONAPI::ResourceIdentity.new(V11::PostResource, 2), + JSONAPI::ResourceIdentity.new(V11::PostResource, 12), + JSONAPI::ResourceIdentity.new(V11::PostResource, 14)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V11::PostResource._relationship('tags') + related_fragments = V11::PostResource.send(:find_included_fragments, source_fragments, relationship, options) + + assert_equal 8, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(V11::TagResource, 501), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V11::TagResource, 501), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal 2, related_fragments[JSONAPI::ResourceIdentity.new(V11::TagResource, 502)].related_from.length + end + + def test_find_related_has_many_fragments_pagination + params = ActionController::Parameters.new(number: 2, size: 4) + options = { paginator: PagedPaginator.new(params) } + source_rids = [JSONAPI::ResourceIdentity.new(V11::PostResource, 15)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V11::PostResource._relationship('tags') + related_fragments = V11::PostResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 1, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(V11::TagResource, 516), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V11::TagResource, 516), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + end + + def test_find_related_has_many_fragments_cache_field + options = { cache: true } + source_rids = [JSONAPI::ResourceIdentity.new(V11::PostResource, 1), + JSONAPI::ResourceIdentity.new(V11::PostResource, 2), + JSONAPI::ResourceIdentity.new(V11::PostResource, 12), + JSONAPI::ResourceIdentity.new(V11::PostResource, 14)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V11::PostResource._relationship('tags') + related_fragments = V11::PostResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 8, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(V11::TagResource, 501), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(V11::TagResource, 501), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal 2, related_fragments[JSONAPI::ResourceIdentity.new(V11::TagResource, 502)].related_from.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_polymorphic_fragments + options = {} + source_rids = [JSONAPI::ResourceIdentity.new(V11::PictureResource, 1), + JSONAPI::ResourceIdentity.new(V11::PictureResource, 2), + JSONAPI::ResourceIdentity.new(V11::PictureResource, 3)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V11::PictureResource._relationship('imageable') + related_fragments = V11::PictureResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 2, related_fragments.length + assert related_fragments.keys.include?(JSONAPI::ResourceIdentity.new(V11::ProductResource, 1)) + assert related_fragments.keys.include?(JSONAPI::ResourceIdentity.new(V11::DocumentResource, 1)) + + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + + assert related_fragments.values.select {|v| v.identity == JSONAPI::ResourceIdentity.new(V11::ProductResource, 1)}.present? + end + + def test_find_related_polymorphic_fragments_cache_field + options = { cache: true } + source_rids = [JSONAPI::ResourceIdentity.new(V11::PictureResource, 1), + JSONAPI::ResourceIdentity.new(V11::PictureResource, 2), + JSONAPI::ResourceIdentity.new(V11::PictureResource, 3)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V11::PictureResource._relationship('imageable') + related_fragments = V11::PictureResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 2, related_fragments.length + assert related_fragments.keys.include?(JSONAPI::ResourceIdentity.new(V11::ProductResource, 1)) + assert related_fragments.keys.include?(JSONAPI::ResourceIdentity.new(V11::DocumentResource, 1)) + + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + assert related_fragments.values[1].cache.is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_polymorphic_fragments_not_cached + options = { cache: false } + source_rids = [JSONAPI::ResourceIdentity.new(V11::PictureResource, 1), + JSONAPI::ResourceIdentity.new(V11::PictureResource, 2), + JSONAPI::ResourceIdentity.new(V11::PictureResource, 3)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + relationship = V11::PictureResource._relationship('imageable') + related_fragments = V11::PictureResource.find_included_fragments(source_fragments, relationship, options) + + assert_equal 2, related_fragments.length + assert related_fragments.keys.include?(JSONAPI::ResourceIdentity.new(V11::ProductResource, 1)) + assert related_fragments.keys.include?(JSONAPI::ResourceIdentity.new(V11::DocumentResource, 1)) + + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + end +end diff --git a/test/unit/resource/relationship_test.rb b/test/unit/resource/relationship_test.rb index a98d26601..88c1e70a0 100644 --- a/test/unit/resource/relationship_test.rb +++ b/test/unit/resource/relationship_test.rb @@ -22,7 +22,8 @@ class HasOneRelationshipTest < ActiveSupport::TestCase def test_polymorphic_type relationship = JSONAPI::Relationship::ToOne.new("imageable", - polymorphic: true + polymorphic: true, + parent_resource: CallableBlogPostsResource ) assert_equal(relationship.polymorphic_type, "imageable_type") end diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index df2df1730..625183095 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -149,19 +149,19 @@ def test_resource_for_nested_namespaced_resource end def test_relationship_parent_point_to_correct_resource - assert_equal MyModule::MyNamespacedResource, MyModule::MyNamespacedResource._relationships[:related].parent_resource + assert_equal MyModule::MyNamespacedResource, MyModule::MyNamespacedResource._relationship(:related).parent_resource end def test_relationship_parent_option_point_to_correct_resource - assert_equal MyModule::MyNamespacedResource, MyModule::MyNamespacedResource._relationships[:related].options[:parent_resource] + assert_equal MyModule::MyNamespacedResource, MyModule::MyNamespacedResource._relationship(:related).options[:parent_resource] end def test_derived_resources_relationships_parent_point_to_correct_resource - assert_equal MyAPI::MyNamespacedResource, MyAPI::MyNamespacedResource._relationships[:related].parent_resource + assert_equal MyAPI::MyNamespacedResource, MyAPI::MyNamespacedResource._relationship(:related).parent_resource end def test_derived_resources_relationships_parent_options_point_to_correct_resource - assert_equal MyAPI::MyNamespacedResource, MyAPI::MyNamespacedResource._relationships[:related].options[:parent_resource] + assert_equal MyAPI::MyNamespacedResource, MyAPI::MyNamespacedResource._relationship(:related).options[:parent_resource] end def test_base_resource_abstract @@ -248,38 +248,46 @@ def test_updatable_fields_does_not_include_id end def test_filter_on_to_many_relationship_id - posts = PostResource.find(:comments => 3) + directives = JSONAPI::IncludeDirectives.new(PostResource, ['comments']) + posts = PostResource.find({ comments: 3 }, { include_directives: directives }) assert_equal([2], posts.map(&:id)) end def test_filter_on_aliased_to_many_relationship_id + directives = JSONAPI::IncludeDirectives.new(BookResource, ['book_comments']) + # Comment 2 is approved - books = Api::V2::BookResource.find(:aliased_comments => 2) + books = Api::V2::BookResource.find({ aliased_comments: 2}, { include_directives: directives }) assert_equal([0], books.map(&:id)) # However, comment 3 is non-approved, so it won't be accessible through this relationship - books = Api::V2::BookResource.find(:aliased_comments => 3) + books = Api::V2::BookResource.find({ aliased_comments: 3}, { include_directives: directives }) assert_equal([], books.map(&:id)) end def test_filter_on_has_one_relationship_id - prefs = PreferencesResource.find(:author => 1001) + directives = JSONAPI::IncludeDirectives.new(PreferencesResource, ['author']) + prefs = PreferencesResource.find({ author: 1001 }, { include_directives: directives }) assert_equal([1], prefs.map(&:id)) end def test_to_many_relationship_filters post_resource = PostResource.new(Post.find(1), nil) - comments = PostResource.find_included_fragments([post_resource], :comments, {}) + comments = PostResource.find_related_fragments(post_resource.fragment, PostResource._relationship(:comments), {}) assert_equal(2, comments.size) - filtered_comments = PostResource.find_included_fragments([post_resource], :comments, { filters: { body: 'i liked it' } }) + filtered_comments = PostResource.find_related_fragments(post_resource.fragment, + PostResource._relationship(:comments), + { filters: { body: 'i liked it' } }) assert_equal(1, filtered_comments.size) end def test_to_many_relationship_sorts post_resource = PostResource.new(Post.find(1), nil) - comment_ids = post_resource.class.find_included_fragments([post_resource], :comments, {}).keys.collect {|c| c.id } + comment_ids = post_resource.class.find_related_fragments(post_resource.fragment, + PostResource._relationship(:comments), + {}).keys.collect {|c| c.id } assert_equal [1,2], comment_ids # define apply_filters method on post resource to sort descending @@ -292,19 +300,19 @@ def apply_sort(records, _order_options, options) end end - sorted_comment_ids = post_resource.class.find_included_fragments( - [post_resource], - :comments, + sorted_comment_ids = post_resource.class.find_related_fragments( + post_resource.fragment, + PostResource._relationship(:comments), { sort_criteria: [{ field: 'id', direction: :desc }] }).keys.collect {|c| c.id} assert_equal [2,1], sorted_comment_ids ensure PostResource.instance_eval do - def apply_sort(records, order_options, context = {}) + def apply_sort(records, order_options, options) if order_options.any? order_options.each_pair do |field, direction| - records = apply_single_sort(records, field, direction, context) + records = apply_single_sort(records, field, direction, options) end end @@ -313,48 +321,6 @@ def apply_sort(records, order_options, context = {}) end end - # ToDo: Implement relationship pagination - # - # def test_to_many_relationship_pagination - # post_resource = PostResource.new(Post.find(1), nil) - # comments = post_resource.comments - # assert_equal 2, comments.size - # - # # define apply_filters method on post resource to not respect filters - # PostResource.instance_eval do - # def apply_pagination(records, criteria, order_options) - # # :nocov: - # records - # # :nocov: - # end - # end - # - # paginator_class = Class.new(JSONAPI::Paginator) do - # def initialize(params) - # # param parsing and validation here - # @page = params.to_i - # end - # - # def apply(relation, order_options) - # relation.offset(@page).limit(1) - # end - # end - # - # paged_comments = post_resource.comments(paginator: paginator_class.new(1)) - # assert_equal 1, paged_comments.size - # - # ensure - # # reset method to original implementation - # PostResource.instance_eval do - # def apply_pagination(records, criteria, order_options) - # # :nocov: - # records = paginator.apply(records, order_options) if paginator - # records - # # :nocov: - # end - # end - # end - def test_key_type_integer FelineResource.instance_eval do key_type :integer diff --git a/test/unit/serializer/include_directives_test.rb b/test/unit/serializer/include_directives_test.rb index 552d13b1b..237ce7e49 100644 --- a/test/unit/serializer/include_directives_test.rb +++ b/test/unit/serializer/include_directives_test.rb @@ -10,7 +10,9 @@ def test_one_level_one_include { include_related: { posts: { - include_related: {} + include: true, + include_related: {}, + include_in_join: true } } }, @@ -24,13 +26,19 @@ def test_one_level_multiple_includes { include_related: { posts: { - include_related: {} + include: true, + include_related: {}, + include_in_join: true }, comments: { - include_related: {} + include: true, + include_related: {}, + include_in_join: true }, expense_entries: { - include_related: {} + include: true, + include_related: {}, + include_in_join: true } } }, @@ -44,17 +52,25 @@ def test_multiple_level_multiple_includes { include_related: { posts: { + include: true, include_related: { comments: { - include_related: {} + include: true, + include_related: {}, + include_in_join: true } - } + }, + include_in_join: true }, comments: { - include_related: {} + include: true, + include_related: {}, + include_in_join: true }, expense_entries: { - include_related: {} + include: true, + include_related: {}, + include_in_join: true } } }, @@ -69,11 +85,15 @@ def test_two_levels_include_full_path { include_related: { posts: { + include: true, include_related: { comments: { - include_related: {} + include: true, + include_related: {}, + include_in_join: true } - } + }, + include_in_join: true } } }, @@ -87,11 +107,15 @@ def test_two_levels_include_full_path_redundant { include_related: { posts: { + include: true, include_related: { comments: { - include_related: {} + include: true, + include_related: {}, + include_in_join: true } - } + }, + include_in_join: true } } }, @@ -105,15 +129,21 @@ def test_three_levels_include_full { include_related: { posts: { + include: true, include_related: { comments: { + include: true, include_related: { tags: { - include_related: {} + include: true, + include_related: {}, + include_in_join: true } - } + }, + include_in_join: true } - } + }, + include_in_join: true } } }, diff --git a/test/unit/serializer/link_builder_test.rb b/test/unit/serializer/link_builder_test.rb index d7c277ad2..fd502400d 100644 --- a/test/unit/serializer/link_builder_test.rb +++ b/test/unit/serializer/link_builder_test.rb @@ -180,7 +180,7 @@ def test_relationships_self_link_not_routed source = primary_resource_klass.new(@great_post, nil) - relationship = Api::Secret::PostResource._relationships[:author] + relationship = Api::Secret::PostResource._relationship(:author) # Should not warn if warn_on_missing_routes is false JSONAPI.configuration.warn_on_missing_routes = false @@ -228,7 +228,7 @@ def test_relationships_related_link_not_routed source = primary_resource_klass.new(@great_post, nil) - relationship = Api::Secret::PostResource._relationships[:author] + relationship = Api::Secret::PostResource._relationship(:author) # Should not warn if warn_on_missing_routes is false JSONAPI.configuration.warn_on_missing_routes = false @@ -366,7 +366,7 @@ def test_relationships_self_link_for_regular_app builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PersonResource.new(@steve, nil) - relationship = Api::V1::PersonResource._relationships[:posts] + relationship = Api::V1::PersonResource._relationship(:posts) expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/relationships/posts" assert_equal expected_link, @@ -383,7 +383,7 @@ def test_relationships_self_link_for_regular_app_singleton builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PreferencesResource.new(@steves_prefs, nil) - relationship = Api::V1::PreferencesResource._relationships[:author] + relationship = Api::V1::PreferencesResource._relationship(:author) expected_link = "#{ @base_url }/api/v1/preferences/relationships/author" assert_equal expected_link, @@ -400,7 +400,7 @@ def test_relationships_related_link_for_regular_app_singleton builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PreferencesResource.new(@steves_prefs, nil) - relationship = Api::V1::PreferencesResource._relationships[:author] + relationship = Api::V1::PreferencesResource._relationship(:author) expected_link = "#{ @base_url }/api/v1/preferences/author" assert_equal expected_link, @@ -417,7 +417,7 @@ def test_relationships_self_link_for_engine builder = JSONAPI::LinkBuilder.new(config) source = ApiV2Engine::PersonResource.new(@steve, nil) - relationship = ApiV2Engine::PersonResource._relationships[:posts] + relationship = ApiV2Engine::PersonResource._relationship(:posts) expected_link = "#{ @base_url }/api_v2/people/#{ @steve.id }/relationships/posts" assert_equal expected_link, @@ -434,7 +434,7 @@ def test_relationships_self_link_for_namespaced_engine builder = JSONAPI::LinkBuilder.new(config) source = MyEngine::Api::V1::PersonResource.new(@steve, nil) - relationship = MyEngine::Api::V1::PersonResource._relationships[:posts] + relationship = MyEngine::Api::V1::PersonResource._relationship(:posts) expected_link = "#{ @base_url }/boomshaka/api/v1/people/#{ @steve.id }/relationships/posts" assert_equal expected_link, @@ -451,7 +451,7 @@ def test_relationships_related_link_for_regular_app builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PersonResource.new(@steve, nil) - relationship = Api::V1::PersonResource._relationships[:posts] + relationship = Api::V1::PersonResource._relationship(:posts) expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/posts" assert_equal expected_link, @@ -468,7 +468,7 @@ def test_relationships_related_link_for_engine builder = JSONAPI::LinkBuilder.new(config) source = ApiV2Engine::PersonResource.new(@steve, nil) - relationship = ApiV2Engine::PersonResource._relationships[:posts] + relationship = ApiV2Engine::PersonResource._relationship(:posts) expected_link = "#{ @base_url }/api_v2/people/#{ @steve.id }/posts" assert_equal expected_link, @@ -485,7 +485,7 @@ def test_relationships_related_link_for_namespaced_engine builder = JSONAPI::LinkBuilder.new(config) source = MyEngine::Api::V1::PersonResource.new(@steve, nil) - relationship = MyEngine::Api::V1::PersonResource._relationships[:posts] + relationship = MyEngine::Api::V1::PersonResource._relationship(:posts) expected_link = "#{ @base_url }/boomshaka/api/v1/people/#{ @steve.id }/posts" assert_equal expected_link, @@ -502,7 +502,7 @@ def test_relationships_related_link_with_query_params builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PersonResource.new(@steve, nil) - relationship = Api::V1::PersonResource._relationships[:posts] + relationship = Api::V1::PersonResource._relationship(:posts) expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/posts?page%5Blimit%5D=12&page%5Boffset%5D=0" query = { page: { offset: 0, limit: 12 } } diff --git a/test/unit/serializer/serializer_test.rb b/test/unit/serializer/serializer_test.rb index 33455b0f1..47e5518a7 100644 --- a/test/unit/serializer/serializer_test.rb +++ b/test/unit/serializer/serializer_test.rb @@ -8,16 +8,8 @@ def setup @fred = Person.find_by(name: 'Fred Reader') @expense_entry = ExpenseEntry.find(1) - - JSONAPI.configuration.json_key_format = :camelized_key - JSONAPI.configuration.route_format = :camelized_route - JSONAPI.configuration.always_include_to_one_linkage_data = false end - def after_teardown - JSONAPI.configuration.always_include_to_one_linkage_data = false - JSONAPI.configuration.json_key_format = :underscored_key - end def test_serializer post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) @@ -258,262 +250,156 @@ def test_serializer_limited_fieldset end def test_serializer_include - post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) - person_1001_identity = JSONAPI::ResourceIdentity.new(PersonResource, 1001) - id_tree = JSONAPI::PrimaryResourceTree.new + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.route_format = :camelized_route + JSONAPI.configuration.always_include_to_one_linkage_data = false - directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) - - id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) - id_tree.complete_includes!(directives[:include_related], {}) - - resource_set = JSONAPI::ResourceSet.new(id_tree) + post_1_resource = PostResource.new(posts(:post_1), {}) + post_1_identity = post_1_resource.identity - serializer = JSONAPI::ResourceSerializer.new(PostResource, - url_helpers: TestApp.routes.url_helpers) + id_tree = JSONAPI::PrimaryResourceTree.new - resource_set.populate!(serializer, {}, {}) - serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) - assert_hash_equals( - { - data: { - type: 'posts', - id: '1', - links: { - self: '/posts/1' - }, - attributes: { - title: 'New post', - body: 'A body!!!', - subject: 'New post' - }, - relationships: { - section: { - links: { - self: '/posts/1/relationships/section', - related: '/posts/1/section' - } - }, - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - }, - data: { - type: 'people', - id: '1001' - } - }, - tags: { - links: { - self: '/posts/1/relationships/tags', - related: '/posts/1/tags' - } - }, - comments: { - links: { - self: '/posts/1/relationships/comments', - related: '/posts/1/comments' - } - } - } - }, - included: [ - { - type: 'people', - id: '1001', - attributes: { - name: 'Joe Author', - email: 'joe@xyz.fake', - dateJoined: '2013-08-07 16:25:00 -0400' - }, - links: { - self: '/people/1001' - }, - relationships: { - comments: { - links: { - self: '/people/1001/relationships/comments', - related: '/people/1001/comments' - } - }, - posts: { - links: { - self: '/people/1001/relationships/posts', - related: '/people/1001/posts' - }, - data: [ - { - type: 'posts', - id: '1' - } - ] - }, - preferences: { - links: { - self: '/people/1001/relationships/preferences', - related: '/people/1001/preferences' - } - }, - hairCut: { - links: { - self: '/people/1001/relationships/hairCut', - related: '/people/1001/hairCut' - } - }, - vehicles: { - links: { - self: '/people/1001/relationships/vehicles', - related: '/people/1001/vehicles' - } - }, - expenseEntries: { - links: { - self: '/people/1001/relationships/expenseEntries', - related: '/people/1001/expenseEntries' - } - } - } - } - ] - }, - serialized - ) - end + id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity, resource: post_1_resource), directives[:include_related]) + id_tree.complete_includes!(directives[:include_related], {}) - def test_serializer_source_to_hash_include - post = posts(:post_1) - post_resource = PostResource.new(post, {}) + resource_set = JSONAPI::ResourceSet.new(id_tree) - serializer = JSONAPI::ResourceSerializer.new( - PostResource, - url_helpers: TestApp.routes.url_helpers, - include_directives: JSONAPI::IncludeDirectives.new(PostResource, ['author'])) + serializer = JSONAPI::ResourceSerializer.new(PostResource, + url_helpers: TestApp.routes.url_helpers) - serialized = serializer.serialize_to_hash(post_resource) + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) - assert_hash_equals( - { - data: { - type: 'posts', - id: '1', - links: { - self: '/posts/1' - }, - attributes: { - title: 'New post', - body: 'A body!!!', - subject: 'New post' - }, - relationships: { - section: { - links: { - self: '/posts/1/relationships/section', - related: '/posts/1/section' - } - }, - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - }, - data: { - type: 'people', - id: '1001' - } - }, - tags: { - links: { - self: '/posts/1/relationships/tags', - related: '/posts/1/tags' - } + assert_hash_equals( + { + data: { + type: 'posts', + id: '1', + links: { + self: '/posts/1' }, - comments: { - links: { - self: '/posts/1/relationships/comments', - related: '/posts/1/comments' - } - } - } - }, - included: [ - { - type: 'people', - id: '1001', attributes: { - name: 'Joe Author', - email: 'joe@xyz.fake', - dateJoined: '2013-08-07 16:25:00 -0400' - }, - links: { - self: '/people/1001' + title: 'New post', + body: 'A body!!!', + subject: 'New post' }, relationships: { - comments: { + section: { links: { - self: '/people/1001/relationships/comments', - related: '/people/1001/comments' + self: '/posts/1/relationships/section', + related: '/posts/1/section' } }, - posts: { + author: { links: { - self: '/people/1001/relationships/posts', - related: '/people/1001/posts' + self: '/posts/1/relationships/author', + related: '/posts/1/author' }, - data: [ - { - type: 'posts', - id: '1' - } - ] - }, - preferences: { - links: { - self: '/people/1001/relationships/preferences', - related: '/people/1001/preferences' + data: { + type: 'people', + id: '1001' } }, - hairCut: { + tags: { links: { - self: '/people/1001/relationships/hairCut', - related: '/people/1001/hairCut' + self: '/posts/1/relationships/tags', + related: '/posts/1/tags' } }, - vehicles: { + comments: { links: { - self: '/people/1001/relationships/vehicles', - related: '/people/1001/vehicles' + self: '/posts/1/relationships/comments', + related: '/posts/1/comments' } + } + } + }, + included: [ + { + type: 'people', + id: '1001', + attributes: { + name: 'Joe Author', + email: 'joe@xyz.fake', + dateJoined: '2013-08-07 16:25:00 -0400' }, - expenseEntries: { - links: { - self: '/people/1001/relationships/expenseEntries', - related: '/people/1001/expenseEntries' - } + links: { + self: '/people/1001' + }, + relationships: { + comments: { + links: { + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' + } + }, + posts: { + links: { + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + } + ] + }, + preferences: { + links: { + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' + } + }, + hairCut: { + links: { + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' + } + }, + vehicles: { + links: { + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' + } + }, + expenseEntries: { + links: { + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' + } + } } } - } - ] - }, - serialized - ) + ] + }, + serialized + ) + end end - def test_serializer_source_array_to_hash_include - post_resources = [PostResource.new(posts(:post_1), {}), PostResource.new(posts(:post_2), {})] + def test_serializer_source_to_hash_include + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.route_format = :camelized_route + JSONAPI.configuration.always_include_to_one_linkage_data = false - serializer = JSONAPI::ResourceSerializer.new( - PostResource, - url_helpers: TestApp.routes.url_helpers, - include_directives: JSONAPI::IncludeDirectives.new(PostResource, ['author'])) + post = posts(:post_1) + post_resource = PostResource.new(post, {}) + + serializer = JSONAPI::ResourceSerializer.new( + PostResource, + url_helpers: TestApp.routes.url_helpers, + include_directives: JSONAPI::IncludeDirectives.new(PostResource, ['author'])) - serialized = serializer.serialize_to_hash(post_resources) + serialized = serializer.serialize_to_hash(post_resource) - assert_hash_equals( - { - data: [ - { + assert_hash_equals( + { + data: { type: 'posts', id: '1', links: { @@ -555,28 +441,115 @@ def test_serializer_source_array_to_hash_include } } }, + included: [ { + type: 'people', + id: '1001', + attributes: { + name: 'Joe Author', + email: 'joe@xyz.fake', + dateJoined: '2013-08-07 16:25:00 -0400' + }, + links: { + self: '/people/1001' + }, + relationships: { + comments: { + links: { + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' + } + }, + posts: { + links: { + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + } + ] + }, + preferences: { + links: { + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' + } + }, + hairCut: { + links: { + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' + } + }, + vehicles: { + links: { + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' + } + }, + expenseEntries: { + links: { + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' + } + } + } + } + ] + }, + serialized + ) + end + end + + def test_serializer_source_array_to_hash_include + skip("Skipping: Currently test is not valid for ActiveRelationRetrievalV09") if testing_v09? + + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.route_format = :camelized_route + JSONAPI.configuration.always_include_to_one_linkage_data = false + + post_1 = posts(:post_1) + post_2 = posts(:post_2) + + post_resources = [PostResource.new(post_1, {}), PostResource.new(post_2, {})] + + serializer = JSONAPI::ResourceSerializer.new( + PostResource, + url_helpers: TestApp.routes.url_helpers, + include_directives: JSONAPI::IncludeDirectives.new(PostResource, ['author'])) + + serialized = serializer.serialize_to_hash(post_resources) + + assert_hash_equals( + { + data: [ + { type: 'posts', - id: '2', + id: '1', links: { - self: '/posts/2' + self: '/posts/1' }, attributes: { - title: 'JR Solves your serialization woes!', - body: 'Use JR', - subject: 'JR Solves your serialization woes!' + title: 'New post', + body: 'A body!!!', + subject: 'New post' }, relationships: { section: { links: { - self: '/posts/2/relationships/section', - related: '/posts/2/section' + self: '/posts/1/relationships/section', + related: '/posts/1/section' } }, author: { links: { - self: '/posts/2/relationships/author', - related: '/posts/2/author' + self: '/posts/1/relationships/author', + related: '/posts/1/author' }, data: { type: 'people', @@ -585,243 +558,365 @@ def test_serializer_source_array_to_hash_include }, tags: { links: { - self: '/posts/2/relationships/tags', - related: '/posts/2/tags' + self: '/posts/1/relationships/tags', + related: '/posts/1/tags' } }, comments: { links: { - self: '/posts/2/relationships/comments', - related: '/posts/2/comments' + self: '/posts/1/relationships/comments', + related: '/posts/1/comments' } } } - } - ], - included: [ - { - type: 'people', - id: '1001', - attributes: { - name: 'Joe Author', - email: 'joe@xyz.fake', - dateJoined: '2013-08-07 16:25:00 -0400' - }, - links: { - self: '/people/1001' }, - relationships: { - comments: { - links: { - self: '/people/1001/relationships/comments', - related: '/people/1001/comments' - } - }, - posts: { + { + type: 'posts', + id: '2', links: { - self: '/people/1001/relationships/posts', - related: '/people/1001/posts' + self: '/posts/2' }, - data: [ - { - type: 'posts', - id: '1' + attributes: { + title: 'JR Solves your serialization woes!', + body: 'Use JR', + subject: 'JR Solves your serialization woes!' + }, + relationships: { + section: { + links: { + self: '/posts/2/relationships/section', + related: '/posts/2/section' + } }, - { - type: 'posts', - id: '2' + author: { + links: { + self: '/posts/2/relationships/author', + related: '/posts/2/author' + }, + data: { + type: 'people', + id: '1001' + } + }, + tags: { + links: { + self: '/posts/2/relationships/tags', + related: '/posts/2/tags' + } + }, + comments: { + links: { + self: '/posts/2/relationships/comments', + related: '/posts/2/comments' + } } - ] - }, - preferences: { - links: { - self: '/people/1001/relationships/preferences', - related: '/people/1001/preferences' - } - }, - hairCut: { - links: { - self: '/people/1001/relationships/hairCut', - related: '/people/1001/hairCut' } + } + ], + included: [ + { + type: 'people', + id: '1001', + attributes: { + name: 'Joe Author', + email: 'joe@xyz.fake', + dateJoined: '2013-08-07 16:25:00 -0400' }, - vehicles: { - links: { - self: '/people/1001/relationships/vehicles', - related: '/people/1001/vehicles' - } + links: { + self: '/people/1001' }, - expenseEntries: { - links: { - self: '/people/1001/relationships/expenseEntries', - related: '/people/1001/expenseEntries' + relationships: { + comments: { + links: { + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' + } + }, + posts: { + links: { + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + }, + { + type: 'posts', + id: '2' + } + ] + }, + preferences: { + links: { + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' + } + }, + hairCut: { + links: { + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' + } + }, + vehicles: { + links: { + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' + } + }, + expenseEntries: { + links: { + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' + } } } } - } - ] - }, - serialized - ) + ] + }, + serialized + ) + end end def test_serializer_key_format - post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) - id_tree = JSONAPI::PrimaryResourceTree.new + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.route_format = :camelized_route + JSONAPI.configuration.always_include_to_one_linkage_data = false - directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) + post_1_resource = PostResource.new(posts(:post_1), {}) + post_1_identity = post_1_resource.identity - id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) - id_tree.complete_includes!(directives[:include_related], {}) + id_tree = JSONAPI::PrimaryResourceTree.new - resource_set = JSONAPI::ResourceSet.new(id_tree) + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) - serializer = JSONAPI::ResourceSerializer.new(PostResource, - key_formatter: UnderscoredKeyFormatter, - url_helpers: TestApp.routes.url_helpers) + id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity, resource: post_1_resource), directives[:include_related]) + id_tree.complete_includes!(directives[:include_related], {}) - resource_set.populate!(serializer, {}, {}) - serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + resource_set = JSONAPI::ResourceSet.new(id_tree) - assert_hash_equals( + serializer = JSONAPI::ResourceSerializer.new(PostResource, + key_formatter: UnderscoredKeyFormatter, + url_helpers: TestApp.routes.url_helpers) + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + + assert_hash_equals( + { + data: { + type: 'posts', + id: '1', + links: { + self: '/posts/1' + }, + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' + }, + relationships: { + section: { + links: { + self: '/posts/1/relationships/section', + related: '/posts/1/section' + } + }, + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' + }, + data: { + type: 'people', + id: '1001' + } + }, + tags: { + links: { + self: '/posts/1/relationships/tags', + related: '/posts/1/tags' + } + }, + comments: { + links: { + self: '/posts/1/relationships/comments', + related: '/posts/1/comments' + } + } + } + }, + included: [ + { + type: 'people', + id: '1001', + attributes: { + name: 'Joe Author', + email: 'joe@xyz.fake', + date_joined: '2013-08-07 16:25:00 -0400' + }, + links: { + self: '/people/1001' + }, + relationships: { + comments: { + links: { + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' + } + }, + posts: { + links: { + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + } + ] + }, + preferences: { + links: { + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' + } + }, + hair_cut: { + links: { + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' + } + }, + vehicles: { + links: { + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' + } + }, + expense_entries: { + links: { + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' + } + } + } + } + ] + }, + serialized + ) + end + end + + def test_serializers_linkage_even_without_included_resource + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.route_format = :camelized_route + JSONAPI.configuration.always_include_to_one_linkage_data = false + + post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) + person_1001_identity = JSONAPI::ResourceIdentity.new(PersonResource, 1001) + + id_tree = JSONAPI::PrimaryResourceTree.new + + directives = JSONAPI::IncludeDirectives.new(PersonResource, []) + + fragment = JSONAPI::ResourceFragment.new(post_1_identity) + + fragment.add_related_identity(:author, person_1001_identity) + fragment.initialize_related(:section) + fragment.initialize_related(:tags) + + id_tree.add_resource_fragment(fragment, directives[:include_related]) + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new(PostResource, + url_helpers: TestApp.routes.url_helpers) + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + + assert_hash_equals( { - data: { - type: 'posts', - id: '1', - links: { - self: '/posts/1' - }, - attributes: { - title: 'New post', - body: 'A body!!!', - subject: 'New post' - }, - relationships: { - section: { - links: { - self: '/posts/1/relationships/section', - related: '/posts/1/section' - } - }, - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - }, - data: { - type: 'people', - id: '1001' - } - }, - tags: { - links: { - self: '/posts/1/relationships/tags', - related: '/posts/1/tags' - } - }, - comments: { - links: { - self: '/posts/1/relationships/comments', - related: '/posts/1/comments' - } - } - } - }, - included: [ - { - type: 'people', - id: '1001', - attributes: { - name: 'Joe Author', - email: 'joe@xyz.fake', - date_joined: '2013-08-07 16:25:00 -0400' - }, + data: + { + id: '1', + type: 'posts', + links: { + self: '/posts/1' + }, + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' + }, + relationships: { + author: { links: { - self: '/people/1001' + self: '/posts/1/relationships/author', + related: '/posts/1/author' }, - relationships: { - comments: { - links: { - self: '/people/1001/relationships/comments', - related: '/people/1001/comments' - } - }, - posts: { - links: { - self: '/people/1001/relationships/posts', - related: '/people/1001/posts' - }, - data: [ - { - type: 'posts', - id: '1' - } - ] - }, - preferences: { - links: { - self: '/people/1001/relationships/preferences', - related: '/people/1001/preferences' - } - }, - hair_cut: { - links: { - self: '/people/1001/relationships/hairCut', - related: '/people/1001/hairCut' - } - }, - vehicles: { - links: { - self: '/people/1001/relationships/vehicles', - related: '/people/1001/vehicles' - } - }, - expense_entries: { - links: { - self: '/people/1001/relationships/expenseEntries', - related: '/people/1001/expenseEntries' - } - } + data: { + type: 'people', + id: '1001' } + }, + section: { + links: { + self: '/posts/1/relationships/section', + related: '/posts/1/section' + }, + data: nil + }, + tags: { + links: { + self: '/posts/1/relationships/tags', + related: '/posts/1/tags' + }, + data: [] + }, + comments: { + links: { + self: '/posts/1/relationships/comments', + related: '/posts/1/comments' + } } - ] + } + } }, serialized - ) + ) + end end - def test_serializers_linkage_even_without_included_resource - - post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) - person_1001_identity = JSONAPI::ResourceIdentity.new(PersonResource, 1001) - - id_tree = JSONAPI::PrimaryResourceTree.new - - directives = JSONAPI::IncludeDirectives.new(PersonResource, []) - - fragment = JSONAPI::ResourceFragment.new(post_1_identity) + def test_serializer_include_from_resource + with_jsonapi_config_changes do + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.route_format = :camelized_route + JSONAPI.configuration.always_include_to_one_linkage_data = false - fragment.add_related_identity(:author, person_1001_identity) - fragment.initialize_related(:section) - fragment.initialize_related(:tags) + serializer = JSONAPI::ResourceSerializer.new(PostResource, url_helpers: TestApp.routes.url_helpers) - id_tree.add_resource_fragment(fragment, directives[:include_related]) - resource_set = JSONAPI::ResourceSet.new(id_tree) + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) - serializer = JSONAPI::ResourceSerializer.new(PostResource, - url_helpers: TestApp.routes.url_helpers) + resource_set = JSONAPI::ResourceSet.new(PostResource.find_by_key(1), directives[:include_related], {}) + resource_set.populate!(serializer, {}, {}) - resource_set.populate!(serializer, {}, {}) - serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) - assert_hash_equals( - { - data: - { - id: '1', + assert_hash_equals( + { + data: { type: 'posts', + id: '1', links: { - self: '/posts/1' + self: '/posts/1' }, attributes: { title: 'New post', @@ -829,29 +924,27 @@ def test_serializers_linkage_even_without_included_resource subject: 'New post' }, relationships: { - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - }, - data: { - type: 'people', - id: '1001' - } - }, section: { links: { self: '/posts/1/relationships/section', related: '/posts/1/section' + } + }, + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' }, - data: nil + data: { + type: 'people', + id: '1001' + } }, tags: { links: { self: '/posts/1/relationships/tags', related: '/posts/1/tags' - }, - data: [] + } }, comments: { links: { @@ -860,127 +953,68 @@ def test_serializers_linkage_even_without_included_resource } } } - } - }, - serialized - ) - end - - def test_serializer_include_from_resource - serializer = JSONAPI::ResourceSerializer.new(PostResource, url_helpers: TestApp.routes.url_helpers) - - directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) - - resource_set = JSONAPI::ResourceSet.new(PostResource.find_by_key(1), directives[:include_related], {}) - resource_set.populate!(serializer, {}, {}) - - serialized = serializer.serialize_resource_set_to_hash_single(resource_set) - - assert_hash_equals( - { - data: { - type: 'posts', - id: '1', - links: { - self: '/posts/1' - }, - attributes: { - title: 'New post', - body: 'A body!!!', - subject: 'New post' }, - relationships: { - section: { - links: { - self: '/posts/1/relationships/section', - related: '/posts/1/section' - } - }, - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' + included: [ + { + type: 'people', + id: '1001', + attributes: { + name: 'Joe Author', + email: 'joe@xyz.fake', + dateJoined: '2013-08-07 16:25:00 -0400' }, - data: { - type: 'people', - id: '1001' - } - }, - tags: { - links: { - self: '/posts/1/relationships/tags', - related: '/posts/1/tags' - } - }, - comments: { links: { - self: '/posts/1/relationships/comments', - related: '/posts/1/comments' - } - } - } - }, - included: [ - { - type: 'people', - id: '1001', - attributes: { - name: 'Joe Author', - email: 'joe@xyz.fake', - dateJoined: '2013-08-07 16:25:00 -0400' - }, - links: { - self: '/people/1001' - }, - relationships: { - comments: { - links: { - self: '/people/1001/relationships/comments', - related: '/people/1001/comments' - } + self: '/people/1001' }, - posts: { - links: { - self: '/people/1001/relationships/posts', - related: '/people/1001/posts' + relationships: { + comments: { + links: { + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' + } }, - data: [ - { - type: 'posts', - id: '1' + posts: { + links: { + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + } + ] + }, + preferences: { + links: { + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' + } + }, + hairCut: { + links: { + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' + } + }, + vehicles: { + links: { + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' + } + }, + expenseEntries: { + links: { + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' } - ] - }, - preferences: { - links: { - self: '/people/1001/relationships/preferences', - related: '/people/1001/preferences' - } - }, - hairCut: { - links: { - self: '/people/1001/relationships/hairCut', - related: '/people/1001/hairCut' - } - }, - vehicles: { - links: { - self: '/people/1001/relationships/vehicles', - related: '/people/1001/vehicles' - } - }, - expenseEntries: { - links: { - self: '/people/1001/relationships/expenseEntries', - related: '/people/1001/expenseEntries' } } } - } - ] - }, - serialized - ) + ] + }, + serialized + ) + end end - end From 9837c367aedf058793633df709742a704d0e24e2 Mon Sep 17 00:00:00 2001 From: lgebhardt Date: Tue, 19 Sep 2023 14:31:32 -0400 Subject: [PATCH 06/34] Add frozen_string_literal: true --- lib/generators/jsonapi/controller_generator.rb | 2 ++ lib/generators/jsonapi/resource_generator.rb | 2 ++ .../adapters/join_left_active_record_adapter.rb | 4 +++- lib/jsonapi/active_relation/join_manager.rb | 2 ++ lib/jsonapi/active_relation/join_manager_v10.rb | 2 ++ lib/jsonapi/active_relation_retrieval.rb | 2 ++ lib/jsonapi/active_relation_retrieval_v09.rb | 2 ++ lib/jsonapi/resources/railtie.rb | 4 +++- lib/jsonapi/resources/version.rb | 2 ++ lib/jsonapi/simple_resource.rb | 2 ++ lib/tasks/check_upgrade.rake | 2 ++ 11 files changed, 24 insertions(+), 2 deletions(-) diff --git a/lib/generators/jsonapi/controller_generator.rb b/lib/generators/jsonapi/controller_generator.rb index 41ee4eb1e..3ebdece11 100644 --- a/lib/generators/jsonapi/controller_generator.rb +++ b/lib/generators/jsonapi/controller_generator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Jsonapi class ControllerGenerator < Rails::Generators::NamedBase source_root File.expand_path('../templates', __FILE__) diff --git a/lib/generators/jsonapi/resource_generator.rb b/lib/generators/jsonapi/resource_generator.rb index 80aa24b4d..34957d1e2 100644 --- a/lib/generators/jsonapi/resource_generator.rb +++ b/lib/generators/jsonapi/resource_generator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Jsonapi class ResourceGenerator < Rails::Generators::NamedBase source_root File.expand_path('../templates', __FILE__) diff --git a/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb b/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb index a9a0bb8a0..2bac4569c 100644 --- a/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb +++ b/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI module ActiveRelation module Adapters @@ -23,4 +25,4 @@ def joins_left(*columns) end end end -end \ No newline at end of file +end diff --git a/lib/jsonapi/active_relation/join_manager.rb b/lib/jsonapi/active_relation/join_manager.rb index eaacf52e0..775831eec 100644 --- a/lib/jsonapi/active_relation/join_manager.rb +++ b/lib/jsonapi/active_relation/join_manager.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI module ActiveRelation diff --git a/lib/jsonapi/active_relation/join_manager_v10.rb b/lib/jsonapi/active_relation/join_manager_v10.rb index 1fc96cc1d..5e1b87658 100644 --- a/lib/jsonapi/active_relation/join_manager_v10.rb +++ b/lib/jsonapi/active_relation/join_manager_v10.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI module ActiveRelation diff --git a/lib/jsonapi/active_relation_retrieval.rb b/lib/jsonapi/active_relation_retrieval.rb index 23758352e..1942d85b3 100644 --- a/lib/jsonapi/active_relation_retrieval.rb +++ b/lib/jsonapi/active_relation_retrieval.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI module ActiveRelationRetrieval def find_related_ids(relationship, options = {}) diff --git a/lib/jsonapi/active_relation_retrieval_v09.rb b/lib/jsonapi/active_relation_retrieval_v09.rb index ef5fafaeb..f3d9ecf3d 100644 --- a/lib/jsonapi/active_relation_retrieval_v09.rb +++ b/lib/jsonapi/active_relation_retrieval_v09.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI module ActiveRelationRetrievalV09 def find_related_ids(relationship, options = {}) diff --git a/lib/jsonapi/resources/railtie.rb b/lib/jsonapi/resources/railtie.rb index a2d92c1c5..f94edfa2a 100644 --- a/lib/jsonapi/resources/railtie.rb +++ b/lib/jsonapi/resources/railtie.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI module Resources class Railtie < Rails::Railtie @@ -6,4 +8,4 @@ class Railtie < Rails::Railtie end end end -end \ No newline at end of file +end diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index fb4178797..66235c030 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI module Resources VERSION = '0.11.0.beta1' diff --git a/lib/jsonapi/simple_resource.rb b/lib/jsonapi/simple_resource.rb index b5bfe5edd..d73edb6dc 100644 --- a/lib/jsonapi/simple_resource.rb +++ b/lib/jsonapi/simple_resource.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'jsonapi/callbacks' require 'jsonapi/configuration' diff --git a/lib/tasks/check_upgrade.rake b/lib/tasks/check_upgrade.rake index 6e6543ebb..869f04e2b 100644 --- a/lib/tasks/check_upgrade.rake +++ b/lib/tasks/check_upgrade.rake @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'rake' require 'jsonapi-resources' From 1b8f05f99682df025e3c26f129b673152cb1ff5e Mon Sep 17 00:00:00 2001 From: lgebhardt Date: Tue, 19 Sep 2023 14:33:29 -0400 Subject: [PATCH 07/34] Restore missing requires --- lib/jsonapi/resource_common.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/jsonapi/resource_common.rb b/lib/jsonapi/resource_common.rb index ca0d67b6f..5c10f07be 100644 --- a/lib/jsonapi/resource_common.rb +++ b/lib/jsonapi/resource_common.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require 'jsonapi/callbacks' +require 'jsonapi/configuration' + module JSONAPI module ResourceCommon From 2dd05896bda6a7208fd80f8e8831ef08e163a671 Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Tue, 19 Sep 2023 14:45:26 -0500 Subject: [PATCH 08/34] fix: warnings (#1401) * fix: warnings * fix: warning * fix: warnings --- test/controllers/controller_test.rb | 174 ++++++++++++---------- test/integration/requests/request_test.rb | 4 +- 2 files changed, 95 insertions(+), 83 deletions(-) diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 4d576c491..12e3cbcc1 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -339,7 +339,7 @@ def test_index_filter_by_ids_and_fields_specify_type def test_index_filter_by_ids_and_fields_specify_unrelated_type assert_cacheable_get :index, params: {filter: {id: '1,2'}, 'fields' => {'currencies' => 'code'}} assert_response :bad_request - assert_match /currencies is not a valid resource./, json_response['errors'][0]['detail'] + assert_match(/currencies is not a valid resource./, json_response['errors'][0]['detail']) end def test_index_filter_by_ids_and_fields_2 @@ -360,9 +360,9 @@ def test_filter_relationship_single end assert_response :success assert_equal 3, json_response['data'].size - assert_match /New post/, response.body - assert_match /JR Solves your serialization woes!/, response.body - assert_match /JR How To/, response.body + assert_match(/New post/, response.body) + assert_match(/JR Solves your serialization woes!/, response.body) + assert_match(/JR How To/, response.body) end def test_filter_relationships_multiple @@ -371,7 +371,7 @@ def test_filter_relationships_multiple end assert_response :success assert_equal 1, json_response['data'].size - assert_match /JR Solves your serialization woes!/, response.body + assert_match(/JR Solves your serialization woes!/, response.body) end def test_filter_relationships_multiple_not_found @@ -383,43 +383,43 @@ def test_filter_relationships_multiple_not_found def test_bad_filter assert_cacheable_get :index, params: {filter: {post_ids: '1,2'}} assert_response :bad_request - assert_match /post_ids is not allowed/, response.body + assert_match(/post_ids is not allowed/, response.body) end def test_bad_filter_value_not_integer_array assert_cacheable_get :index, params: {filter: {id: 'asdfg'}} assert_response :bad_request - assert_match /asdfg is not a valid value for id/, response.body + assert_match(/asdfg is not a valid value for id/, response.body) end def test_bad_filter_value_not_integer assert_cacheable_get :index, params: {filter: {id: 'asdfg'}} assert_response :bad_request - assert_match /asdfg is not a valid value for id/, response.body + assert_match(/asdfg is not a valid value for id/, response.body) end def test_bad_filter_value_not_found_array assert_cacheable_get :index, params: {filter: {id: '5412333'}} assert_response :not_found - assert_match /5412333 could not be found/, response.body + assert_match(/5412333 could not be found/, response.body) end def test_bad_filter_value_not_found assert_cacheable_get :index, params: {filter: {id: '5412333'}} assert_response :not_found - assert_match /5412333 could not be found/, json_response['errors'][0]['detail'] + assert_match(/5412333 could not be found/, json_response['errors'][0]['detail']) end def test_field_not_supported assert_cacheable_get :index, params: {filter: {id: '1,2'}, 'fields' => {'posts' => 'id,title,rank,author'}} assert_response :bad_request - assert_match /rank is not a valid field for posts./, json_response['errors'][0]['detail'] + assert_match(/rank is not a valid field for posts./, json_response['errors'][0]['detail']) end def test_resource_not_supported assert_cacheable_get :index, params: {filter: {id: '1,2'}, 'fields' => {'posters' => 'id,title'}} assert_response :bad_request - assert_match /posters is not a valid resource./, json_response['errors'][0]['detail'] + assert_match(/posters is not a valid resource./, json_response['errors'][0]['detail']) end def test_index_filter_on_relationship @@ -461,7 +461,7 @@ def create_alphabetically_first_user_and_post end def test_sorting_by_relationship_field - post = create_alphabetically_first_user_and_post + _post = create_alphabetically_first_user_and_post assert_cacheable_get :index, params: {sort: 'author.name'} assert_response :success @@ -478,7 +478,7 @@ def test_sorting_by_relationship_field end def test_desc_sorting_by_relationship_field - post = create_alphabetically_first_user_and_post + _post = create_alphabetically_first_user_and_post assert_cacheable_get :index, params: {sort: '-author.name'} assert_response :success @@ -496,7 +496,7 @@ def test_desc_sorting_by_relationship_field end def test_sorting_by_relationship_field_include - post = create_alphabetically_first_user_and_post + _post = create_alphabetically_first_user_and_post assert_cacheable_get :index, params: {include: 'author', sort: 'author.name'} assert_response :success @@ -517,7 +517,7 @@ def test_invalid_sort_param assert_cacheable_get :index, params: {sort: 'asdfg'} assert_response :bad_request - assert_match /asdfg is not a valid sort criteria for post/, response.body + assert_match(/asdfg is not a valid sort criteria for post/, response.body) end def test_show_single_with_sort_disallowed @@ -532,7 +532,7 @@ def test_excluded_sort_param assert_cacheable_get :index, params: {sort: 'id'} assert_response :bad_request - assert_match /id is not a valid sort criteria for post/, response.body + assert_match(/id is not a valid sort criteria for post/, response.body) end def test_show_single_no_includes @@ -647,31 +647,31 @@ def test_show_single_with_fields def test_show_single_with_fields_string assert_cacheable_get :show, params: {id: '1', fields: 'author'} assert_response :bad_request - assert_match /Fields must specify a type./, json_response['errors'][0]['detail'] + assert_match(/Fields must specify a type./, json_response['errors'][0]['detail']) end def test_show_single_invalid_id_format assert_cacheable_get :show, params: {id: 'asdfg'} assert_response :bad_request - assert_match /asdfg is not a valid value for id/, response.body + assert_match(/asdfg is not a valid value for id/, response.body) end def test_show_single_missing_record assert_cacheable_get :show, params: {id: '5412333'} assert_response :not_found - assert_match /record identified by 5412333 could not be found/, response.body + assert_match(/record identified by 5412333 could not be found/, response.body) end def test_show_malformed_fields_not_list assert_cacheable_get :show, params: {id: '1', 'fields' => ''} assert_response :bad_request - assert_match /Fields must specify a type./, json_response['errors'][0]['detail'] + assert_match(/Fields must specify a type./, json_response['errors'][0]['detail']) end def test_show_malformed_fields_type_not_list assert_cacheable_get :show, params: {id: '1', 'fields' => {'posts' => ''}} assert_response :bad_request - assert_match /nil is not a valid field for posts./, json_response['errors'][0]['detail'] + assert_match(/nil is not a valid field for posts./, json_response['errors'][0]['detail']) end def test_create_simple @@ -715,7 +715,7 @@ def test_create_simple_id_not_allowed } assert_response :bad_request - assert_match /id is not allowed/, response.body + assert_match(/id is not allowed/, response.body) assert_nil response.location end @@ -737,7 +737,7 @@ def test_create_link_to_missing_object assert_response :unprocessable_entity # TODO: check if this validation is working - assert_match /author - can't be blank/, response.body + assert_match(/author - can't be blank/, response.body) assert_nil response.location end @@ -758,7 +758,7 @@ def test_create_bad_relationship_array } assert_response :bad_request - assert_match /Data is not a valid Links Object./, response.body + assert_match(/Data is not a valid Links Object./, response.body) end def test_create_extra_param @@ -779,7 +779,7 @@ def test_create_extra_param } assert_response :bad_request - assert_match /asdfg is not allowed/, response.body + assert_match(/asdfg is not allowed/, response.body) assert_nil response.location end @@ -879,7 +879,7 @@ def test_create_multiple } assert_response :bad_request - assert_match /Invalid data format/, response.body + assert_match(/Invalid data format/, response.body) end def test_create_simple_missing_posts @@ -899,7 +899,7 @@ def test_create_simple_missing_posts } assert_response :bad_request - assert_match /The required parameter, data, is missing./, json_response['errors'][0]['detail'] + assert_match(/The required parameter, data, is missing./, json_response['errors'][0]['detail']) assert_nil response.location end @@ -920,7 +920,7 @@ def test_create_simple_wrong_type } assert_response :bad_request - assert_match /posts_spelled_wrong is not a valid resource./, json_response['errors'][0]['detail'] + assert_match(/posts_spelled_wrong is not a valid resource./, json_response['errors'][0]['detail']) assert_nil response.location end @@ -940,7 +940,7 @@ def test_create_simple_missing_type } assert_response :bad_request - assert_match /The required parameter, type, is missing./, json_response['errors'][0]['detail'] + assert_match(/The required parameter, type, is missing./, json_response['errors'][0]['detail']) assert_nil response.location end @@ -961,7 +961,7 @@ def test_create_simple_unpermitted_attributes } assert_response :bad_request - assert_match /subject/, json_response['errors'][0]['detail'] + assert_match(/subject/, json_response['errors'][0]['detail']) assert_nil response.location end @@ -1263,7 +1263,7 @@ def test_update_relationship_to_one_invalid_links_hash_keys_ids put :update_relationship, params: {post_id: 3, relationship: 'section', data: {type: 'sections', ids: 'foo'}} assert_response :bad_request - assert_match /Invalid Links Object/, response.body + assert_match(/Invalid Links Object/, response.body) end def test_update_relationship_to_one_invalid_links_hash_count @@ -1271,7 +1271,7 @@ def test_update_relationship_to_one_invalid_links_hash_count put :update_relationship, params: {post_id: 3, relationship: 'section', data: {type: 'sections'}} assert_response :bad_request - assert_match /Invalid Links Object/, response.body + assert_match(/Invalid Links Object/, response.body) end def test_update_relationship_to_many_not_array @@ -1279,7 +1279,7 @@ def test_update_relationship_to_many_not_array put :update_relationship, params: {post_id: 3, relationship: 'tags', data: {type: 'tags', id: 502}} assert_response :bad_request - assert_match /Invalid Links Object/, response.body + assert_match(/Invalid Links Object/, response.body) end def test_update_relationship_to_one_invalid_links_hash_keys_type_mismatch @@ -1287,7 +1287,7 @@ def test_update_relationship_to_one_invalid_links_hash_keys_type_mismatch put :update_relationship, params: {post_id: 3, relationship: 'section', data: {type: 'comment', id: '3'}} assert_response :bad_request - assert_match /Type Mismatch/, response.body + assert_match(/Type Mismatch/, response.body) end def test_update_nil_to_many_links @@ -1305,7 +1305,7 @@ def test_update_nil_to_many_links } assert_response :bad_request - assert_match /Invalid Links Object/, response.body + assert_match(/Invalid Links Object/, response.body) end def test_update_bad_hash_to_many_links @@ -1323,7 +1323,7 @@ def test_update_bad_hash_to_many_links } assert_response :bad_request - assert_match /Invalid Links Object/, response.body + assert_match(/Invalid Links Object/, response.body) end def test_update_other_to_many_links @@ -1341,7 +1341,7 @@ def test_update_other_to_many_links } assert_response :bad_request - assert_match /Invalid Links Object/, response.body + assert_match(/Invalid Links Object/, response.body) end def test_update_other_to_many_links_data_nil @@ -1359,7 +1359,7 @@ def test_update_other_to_many_links_data_nil } assert_response :bad_request - assert_match /Invalid Links Object/, response.body + assert_match(/Invalid Links Object/, response.body) end def test_update_relationship_to_one_singular_param_id_nil @@ -1506,7 +1506,7 @@ def test_create_relationship_to_many_mismatched_type post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'comments', id: 5}]} assert_response :bad_request - assert_match /Type Mismatch/, response.body + assert_match(/Type Mismatch/, response.body) end def test_create_relationship_to_many_missing_id @@ -1514,7 +1514,7 @@ def test_create_relationship_to_many_missing_id post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', idd: 505}]} assert_response :bad_request - assert_match /Data is not a valid Links Object./, response.body + assert_match(/Data is not a valid Links Object./, response.body) end def test_create_relationship_to_many_not_array @@ -1522,7 +1522,7 @@ def test_create_relationship_to_many_not_array post :create_relationship, params: {post_id: 3, relationship: 'tags', data: {type: 'tags', id: 505}} assert_response :bad_request - assert_match /Data is not a valid Links Object./, response.body + assert_match(/Data is not a valid Links Object./, response.body) end def test_create_relationship_to_many_missing_data @@ -1530,7 +1530,7 @@ def test_create_relationship_to_many_missing_data post :create_relationship, params: {post_id: 3, relationship: 'tags'} assert_response :bad_request - assert_match /The required parameter, data, is missing./, response.body + assert_match(/The required parameter, data, is missing./, response.body) end def test_create_relationship_to_many_join_table_no_reflection @@ -1614,7 +1614,7 @@ def test_update_relationship_to_many_missing_tags put :update_relationship, params: {post_id: 3, relationship: 'tags'} assert_response :bad_request - assert_match /The required parameter, data, is missing./, response.body + assert_match(/The required parameter, data, is missing./, response.body) end def test_delete_relationship_to_many @@ -1718,7 +1718,7 @@ def test_update_mismatch_single_key } assert_response :bad_request - assert_match /The URL does not support the key 2/, response.body + assert_match(/The URL does not support the key 2/, response.body) end def test_update_extra_param @@ -1743,7 +1743,7 @@ def test_update_extra_param } assert_response :bad_request - assert_match /asdfg is not allowed/, response.body + assert_match(/asdfg is not allowed/, response.body) end def test_update_extra_param_in_links @@ -1768,7 +1768,7 @@ def test_update_extra_param_in_links } assert_response :bad_request - assert_match /asdfg is not allowed/, response.body + assert_match(/asdfg is not allowed/, response.body) end def test_update_extra_param_in_links_allow_extra_params @@ -1777,7 +1777,7 @@ def test_update_extra_param_in_links_allow_extra_params JSONAPI.configuration.use_text_errors = true set_content_type_header! - javascript = Section.find_by(name: 'javascript') + _javascript = Section.find_by(name: 'javascript') put :update, params: { @@ -1822,7 +1822,7 @@ def test_update_missing_param } assert_response :bad_request - assert_match /The required parameter, data, is missing./, response.body + assert_match(/The required parameter, data, is missing./, response.body) end def test_update_missing_key @@ -1840,7 +1840,7 @@ def test_update_missing_key } assert_response :bad_request - assert_match /The resource object does not contain a key/, response.body + assert_match(/The resource object does not contain a key/, response.body) end def test_update_missing_type @@ -1864,7 +1864,7 @@ def test_update_missing_type } assert_response :bad_request - assert_match /The required parameter, type, is missing./, response.body + assert_match(/The required parameter, type, is missing./, response.body) end def test_update_unknown_key @@ -1889,7 +1889,7 @@ def test_update_unknown_key } assert_response :bad_request - assert_match /body is not allowed/, response.body + assert_match(/body is not allowed/, response.body) end def test_update_multiple_ids @@ -1913,7 +1913,7 @@ def test_update_multiple_ids } assert_response :bad_request - assert_match /The URL does not support the key 3/, response.body + assert_match(/The URL does not support the key 3/, response.body) end def test_update_multiple_array @@ -1940,7 +1940,7 @@ def test_update_multiple_array } assert_response :bad_request - assert_match /Invalid data format/, response.body + assert_match(/Invalid data format/, response.body) end def test_update_unpermitted_attributes @@ -1962,7 +1962,7 @@ def test_update_unpermitted_attributes } assert_response :bad_request - assert_match /subject is not allowed./, response.body + assert_match(/subject is not allowed./, response.body) end def test_update_bad_attributes @@ -2014,7 +2014,7 @@ def test_delete_multiple initial_count = Post.count delete :destroy, params: {id: '5,6'} assert_response :bad_request - assert_match /5,6 is not a valid value for id/, response.body + assert_match(/5,6 is not a valid value for id/, response.body) assert_equal initial_count, Post.count end @@ -2051,7 +2051,7 @@ def test_show_to_many_relationship def test_show_to_many_relationship_invalid_id assert_cacheable_get :show_relationship, params: {post_id: '2,1', relationship: 'tags'} assert_response :bad_request - assert_match /2,1 is not a valid value for id/, response.body + assert_match(/2,1 is not a valid value for id/, response.body) end def test_show_to_one_relationship_nil @@ -2110,25 +2110,25 @@ def test_tags_index_include_nested_tree def test_tags_show_multiple assert_cacheable_get :show, params: { id: '506,507,508,509' } assert_response :bad_request - assert_match /506,507,508,509 is not a valid value for id/, response.body + assert_match(/506,507,508,509 is not a valid value for id/, response.body) end def test_tags_show_multiple_with_include assert_cacheable_get :show, params: { id: '506,507,508,509', include: 'posts.tags,posts.author.posts' } assert_response :bad_request - assert_match /506,507,508,509 is not a valid value for id/, response.body + assert_match(/506,507,508,509 is not a valid value for id/, response.body) end def test_tags_show_multiple_with_nonexistent_ids assert_cacheable_get :show, params: { id: '506,5099,509,50100' } assert_response :bad_request - assert_match /506,5099,509,50100 is not a valid value for id/, response.body + assert_match(/506,5099,509,50100 is not a valid value for id/, response.body) end def test_tags_show_multiple_with_nonexistent_ids_at_the_beginning assert_cacheable_get :show, params: { id: '5099,509,50100' } assert_response :bad_request - assert_match /5099,509,50100 is not a valid value for id/, response.body + assert_match(/5099,509,50100 is not a valid value for id/, response.body) end def test_nested_includes_sort @@ -2266,7 +2266,7 @@ def test_expense_entries_show_bad_include_missing_relationship assert_cacheable_get :show, params: { id: 1, include: 'isoCurrencies,employees' } assert_response :bad_request - assert_match /isoCurrencies is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail'] + assert_match(/isoCurrencies is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail']) end end @@ -2276,7 +2276,7 @@ def test_expense_entries_show_bad_include_missing_sub_relationship assert_cacheable_get :show, params: { id: 1, include: 'isoCurrency,employee.post' } assert_response :bad_request - assert_match /post is not a valid includable relationship of employees/, json_response['errors'][0]['detail'] + assert_match(/post is not a valid includable relationship of employees/, json_response['errors'][0]['detail']) end end @@ -2286,7 +2286,7 @@ def test_invalid_include assert_cacheable_get :index, params: { include: 'invalid../../../../' } assert_response :bad_request - assert_match /invalid is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail'] + assert_match(/invalid is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail']) end end @@ -2296,7 +2296,7 @@ def test_invalid_include_long_garbage_string assert_cacheable_get :index, params: { include: 'invalid.foo.bar.dfsdfs,dfsdfs.sdfwe.ewrerw.erwrewrew' } assert_response :bad_request - assert_match /invalid is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail'] + assert_match(/invalid is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail']) end end @@ -2635,8 +2635,8 @@ def test_create_validations_missing_attribute assert_equal 2, json_response['errors'].size assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][0]['code'] assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][1]['code'] - assert_match /dateJoined - can't be blank/, response.body - assert_match /name - can't be blank/, response.body + assert_match(/dateJoined - can't be blank/, response.body) + assert_match(/name - can't be blank/, response.body) end end @@ -2660,7 +2660,7 @@ def test_update_validations_missing_attribute assert_response :unprocessable_entity assert_equal 1, json_response['errors'].size assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][0]['code'] - assert_match /name - can't be blank/, response.body + assert_match(/name - can't be blank/, response.body) end end @@ -2985,7 +2985,7 @@ def test_poro_show_multiple assert_cacheable_get :show, params: { id: '0,2' } assert_response :bad_request - assert_match /0,2 is not a valid value for id/, response.body + assert_match(/0,2 is not a valid value for id/, response.body) end def test_poro_create_simple @@ -3019,7 +3019,7 @@ def test_poro_create_validation_error assert_equal 1, json_response['errors'].size assert_equal JSONAPI::VALIDATION_ERROR, json_response['errors'][0]['code'] - assert_match /name - can't be blank/, response.body + assert_match(/name - can't be blank/, response.body) end def test_poro_create_update @@ -3340,7 +3340,7 @@ def test_books_offset_pagination_bad_page_param assert_cacheable_get :index, params: { page: { offset_bad: 50, limit: 12 } } assert_response :bad_request - assert_match /offset_bad is not an allowed page parameter./, json_response['errors'][0]['detail'] + assert_match(/offset_bad is not an allowed page parameter./, json_response['errors'][0]['detail']) end def test_books_offset_pagination_bad_param_value_limit_to_large @@ -3348,7 +3348,7 @@ def test_books_offset_pagination_bad_param_value_limit_to_large assert_cacheable_get :index, params: { page: { offset: 50, limit: 1000 } } assert_response :bad_request - assert_match /Limit exceeds maximum page size of 20./, json_response['errors'][0]['detail'] + assert_match(/Limit exceeds maximum page size of 20./, json_response['errors'][0]['detail']) end def test_books_offset_pagination_bad_param_value_limit_too_small @@ -3356,7 +3356,7 @@ def test_books_offset_pagination_bad_param_value_limit_too_small assert_cacheable_get :index, params: { page: { offset: 50, limit: -1 } } assert_response :bad_request - assert_match /-1 is not a valid value for limit page parameter./, json_response['errors'][0]['detail'] + assert_match(/-1 is not a valid value for limit page parameter./, json_response['errors'][0]['detail']) end def test_books_offset_pagination_bad_param_offset_less_than_zero @@ -3364,7 +3364,7 @@ def test_books_offset_pagination_bad_param_offset_less_than_zero assert_cacheable_get :index, params: { page: { offset: -1, limit: 20 } } assert_response :bad_request - assert_match /-1 is not a valid value for offset page parameter./, json_response['errors'][0]['detail'] + assert_match(/-1 is not a valid value for offset page parameter./, json_response['errors'][0]['detail']) end def test_books_offset_pagination_invalid_page_format @@ -3372,7 +3372,7 @@ def test_books_offset_pagination_invalid_page_format assert_cacheable_get :index, params: { page: 50 } assert_response :bad_request - assert_match /Invalid Page Object./, json_response['errors'][0]['detail'] + assert_match(/Invalid Page Object./, json_response['errors'][0]['detail']) end def test_books_paged_pagination_no_params @@ -3407,7 +3407,7 @@ def test_books_paged_pagination_bad_page_param assert_cacheable_get :index, params: { page: { number_bad: 50, size: 12 } } assert_response :bad_request - assert_match /number_bad is not an allowed page parameter./, json_response['errors'][0]['detail'] + assert_match(/number_bad is not an allowed page parameter./, json_response['errors'][0]['detail']) end def test_books_paged_pagination_bad_param_value_limit_to_large @@ -3415,7 +3415,7 @@ def test_books_paged_pagination_bad_param_value_limit_to_large assert_cacheable_get :index, params: { page: { number: 50, size: 1000 } } assert_response :bad_request - assert_match /size exceeds maximum page size of 20./, json_response['errors'][0]['detail'] + assert_match(/size exceeds maximum page size of 20./, json_response['errors'][0]['detail']) end def test_books_paged_pagination_bad_param_value_limit_too_small @@ -3423,7 +3423,7 @@ def test_books_paged_pagination_bad_param_value_limit_too_small assert_cacheable_get :index, params: { page: { number: 50, size: -1 } } assert_response :bad_request - assert_match /-1 is not a valid value for size page parameter./, json_response['errors'][0]['detail'] + assert_match(/-1 is not a valid value for size page parameter./, json_response['errors'][0]['detail']) end def test_books_paged_pagination_invalid_page_format_incorrect @@ -3431,7 +3431,7 @@ def test_books_paged_pagination_invalid_page_format_incorrect assert_cacheable_get :index, params: { page: 'qwerty' } assert_response :bad_request - assert_match /0 is not a valid value for number page parameter./, json_response['errors'][0]['detail'] + assert_match(/0 is not a valid value for number page parameter./, json_response['errors'][0]['detail']) end def test_books_paged_pagination_invalid_page_format_interpret_int @@ -3825,7 +3825,7 @@ def test_save_model_callbacks_fail } assert_response :unprocessable_entity - assert_match /Save failed or was cancelled/, json_response['errors'][0]['detail'] + assert_match(/Save failed or was cancelled/, json_response['errors'][0]['detail']) end end @@ -4058,7 +4058,7 @@ def test_uncaught_error_in_controller_translated_to_internal_server_error get :show, params: { id: '1' } assert_response 500 - assert_match /Internal Server Error/, json_response['errors'][0]['detail'] + assert_match(/Internal Server Error/, json_response['errors'][0]['detail']) end def test_not_allowed_error_in_controller @@ -4066,7 +4066,17 @@ def test_not_allowed_error_in_controller JSONAPI.configuration.exception_class_allowlist = [] get :show, params: { id: '1' } assert_response 500 - assert_match /Internal Server Error/, json_response['errors'][0]['detail'] + assert_match(/Internal Server Error/, json_response['errors'][0]['detail']) + end + end + + def test_not_allowlisted_error_in_controller + with_jsonapi_config_changes do + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.exception_class_allowlist = [] + get :show, params: {id: '1'} + assert_response 500 + assert_match(/Internal Server Error/, json_response['errors'][0]['detail']) end end diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 3eee0f97b..0d23a08da 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -1141,6 +1141,8 @@ def test_patch_formatted_dasherized } assert_jsonapi_response 200 + ensure + JSONAPI.configuration = original_config end def test_patch_formatted_dasherized_links @@ -1376,7 +1378,7 @@ def test_deprecated_include_message JSONAPI.configuration.allow_include = false CODE end - assert_match /DEPRECATION WARNING: `allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options./, err + assert_match(/DEPRECATION WARNING: `allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options./, err) ensure JSONAPI.configuration = original_config ActiveSupport::Deprecation.silenced = true From 125cef4663b0c4c6cdb6e5e6f20db8ee2f7bdcc9 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 26 Sep 2023 09:43:33 -0400 Subject: [PATCH 09/34] Restore `use_related_resource_records_for_joins` for v0_10 (#1412) * Restore `use_related_resource_records_for_joins` for v0_10 * Handle nil actual hashes * Add back join_options for v10 compatibility * Test JoinManager not JoinManagerV10 * Use sql_for_compare to account for different sql dialect quoating --- .../active_relation/join_manager_v10.rb | 10 +- lib/jsonapi/active_relation_retrieval_v10.rb | 7 +- lib/jsonapi/configuration.rb | 10 +- lib/jsonapi/relationship.rb | 11 +- test/helpers/assertions.rb | 2 +- .../join_manager_test.rb | 36 +++--- .../join_manager_v10_test.rb | 108 ++++++++++-------- 7 files changed, 113 insertions(+), 71 deletions(-) diff --git a/lib/jsonapi/active_relation/join_manager_v10.rb b/lib/jsonapi/active_relation/join_manager_v10.rb index 5e1b87658..07ca09789 100644 --- a/lib/jsonapi/active_relation/join_manager_v10.rb +++ b/lib/jsonapi/active_relation/join_manager_v10.rb @@ -149,9 +149,15 @@ def perform_joins(records, options) related_resource_klass = join_details[:related_resource_klass] join_type = relationship_details[:join_type] + join_options = { + relationship: relationship, + relationship_details: relationship_details, + related_resource_klass: related_resource_klass, + } + if relationship == :root unless source_relationship - add_join_details('', {alias: resource_klass._table_name, join_type: :root}) + add_join_details('', {alias: resource_klass._table_name, join_type: :root, join_options: join_options}) end next end @@ -165,7 +171,7 @@ def perform_joins(records, options) options: options) } - details = {alias: self.class.alias_from_arel_node(join_node), join_type: join_type} + details = {alias: self.class.alias_from_arel_node(join_node), join_type: join_type, join_options: join_options} if relationship == source_relationship if relationship.polymorphic? && relationship.belongs_to? diff --git a/lib/jsonapi/active_relation_retrieval_v10.rb b/lib/jsonapi/active_relation_retrieval_v10.rb index 5f051c8ad..9464af768 100644 --- a/lib/jsonapi/active_relation_retrieval_v10.rb +++ b/lib/jsonapi/active_relation_retrieval_v10.rb @@ -101,7 +101,7 @@ def find_fragments(filters, options = {}) sort_criteria = options.fetch(:sort_criteria) { [] } - join_manager = ActiveRelation::JoinManager.new(resource_klass: resource_klass, + join_manager = ActiveRelation::JoinManagerV10.new(resource_klass: resource_klass, source_relationship: nil, relationships: linkage_relationships.collect(&:name), sort_criteria: sort_criteria, @@ -316,6 +316,11 @@ def apply_join(records:, relationship:, resource_type:, join_type:, options:) records = records.joins_left(relation_name) end end + + if relationship.use_related_resource_records_for_joins + records = records.merge(self.records(options)) + end + records end diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index c5dc723de..6d6c7850b 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -42,7 +42,8 @@ class Configuration :resource_cache_digest_function, :resource_cache_usage_report_function, :default_exclude_links, - :default_resource_retrieval_strategy + :default_resource_retrieval_strategy, + :use_related_resource_records_for_joins def initialize #:underscored_key, :camelized_key, :dasherized_key, or custom @@ -176,6 +177,11 @@ def initialize # :none # :self self.default_resource_retrieval_strategy = 'JSONAPI::ActiveRelationRetrieval' + + # For 'JSONAPI::ActiveRelationRetrievalV10': use a related resource's `records` when performing joins. + # This setting allows included resources to account for permission scopes. It can be overridden explicitly per + # relationship. Furthermore, specifying a `relation_name` on a relationship will cause this setting to be ignored. + self.use_related_resource_records_for_joins = true end def cache_formatters=(bool) @@ -319,6 +325,8 @@ def allow_include=(allow_include) attr_writer :default_exclude_links attr_writer :default_resource_retrieval_strategy + + attr_writer :use_related_resource_records_for_joins end class << self diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 2308b0a34..31a053b29 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -5,7 +5,7 @@ class Relationship attr_reader :acts_as_set, :foreign_key, :options, :name, :class_name, :polymorphic, :always_include_optional_linkage_data, :exclude_linkage_data, :parent_resource, :eager_load_on_include, :custom_methods, - :inverse_relationship, :allow_include, :hidden + :inverse_relationship, :allow_include, :hidden, :use_related_resource_records_for_joins attr_writer :allow_include @@ -25,6 +25,15 @@ def initialize(name, options = {}) @polymorphic_types ||= options[:polymorphic_relations] end + use_related_resource_records_for_joins_default = if options[:relation_name] + false + else + JSONAPI.configuration.use_related_resource_records_for_joins + end + + @use_related_resource_records_for_joins = options.fetch(:use_related_resource_records_for_joins, + use_related_resource_records_for_joins_default) == true + @hidden = options.fetch(:hidden, false) == true @exclude_linkage_data = options[:exclude_linkage_data] diff --git a/test/helpers/assertions.rb b/test/helpers/assertions.rb index 42b282345..eee85efc8 100644 --- a/test/helpers/assertions.rb +++ b/test/helpers/assertions.rb @@ -1,7 +1,7 @@ module Helpers module Assertions def assert_hash_equals(exp, act, msg = nil) - msg = message(msg, '') { diff exp.deep_stringify_keys, act.deep_stringify_keys } + msg = message(msg, '') { diff exp.deep_stringify_keys, act&.deep_stringify_keys } assert(matches_hash?(exp, act, {exact: true}), msg) end diff --git a/test/unit/active_relation_resource_finder/join_manager_test.rb b/test/unit/active_relation_resource_finder/join_manager_test.rb index 5b46d3450..53799e9e4 100644 --- a/test/unit/active_relation_resource_finder/join_manager_test.rb +++ b/test/unit/active_relation_resource_finder/join_manager_test.rb @@ -11,7 +11,7 @@ class JoinManagerTest < ActiveSupport::TestCase # end def test_no_added_joins - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource) records = PostResource.records({}) records = join_manager.join(records, {}) @@ -22,7 +22,7 @@ def test_no_added_joins def test_add_single_join filters = {'tags' => ['1']} - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, filters: filters) records = PostResource.records({}) records = join_manager.join(records, {}) assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) @@ -32,7 +32,7 @@ def test_add_single_join def test_add_single_sort_join sort_criteria = [{field: 'tags.name', direction: :desc}] - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, sort_criteria: sort_criteria) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, sort_criteria: sort_criteria) records = PostResource.records({}) records = join_manager.join(records, {}) @@ -44,7 +44,7 @@ def test_add_single_sort_join def test_add_single_sort_and_filter_join filters = {'tags' => ['1']} sort_criteria = [{field: 'tags.name', direction: :desc}] - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, sort_criteria: sort_criteria, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, sort_criteria: sort_criteria, filters: filters) records = PostResource.records({}) records = join_manager.join(records, {}) assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) @@ -58,7 +58,7 @@ def test_add_sibling_joins 'author' => ['1'] } - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, filters: filters) records = PostResource.records({}) records = join_manager.join(records, {}) @@ -70,7 +70,7 @@ def test_add_sibling_joins def test_add_joins_source_relationship - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, source_relationship: PostResource._relationship(:comments)) records = PostResource.records({}) records = join_manager.join(records, {}) @@ -81,7 +81,7 @@ def test_add_joins_source_relationship def test_add_joins_source_relationship_with_custom_apply - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, source_relationship: Api::V10::PostResource._relationship(:comments)) records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) @@ -100,7 +100,7 @@ def test_add_nested_scoped_joins 'author' => ['1'] } - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, filters: filters) records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) @@ -117,7 +117,7 @@ def test_add_nested_scoped_joins 'comments.tags' => ['1'] } - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, filters: filters) records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) @@ -135,7 +135,7 @@ def test_add_nested_joins_with_fields 'author.foo' => ['1'] } - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, filters: filters) records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) @@ -149,14 +149,14 @@ def test_add_nested_joins_with_fields def test_add_joins_with_sub_relationship relationships = %w(author author.comments tags) - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: Api::V10::PostResource, relationships: relationships, + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, relationships: relationships, source_relationship: Api::V10::PostResource._relationship(:comments)) records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:tags))) assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PersonResource._relationship(:comments))) end @@ -168,7 +168,7 @@ def test_add_joins_with_sub_relationship_and_filters relationships = %w(author author.comments tags) - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, filters: filters, relationships: relationships, source_relationship: PostResource._relationship(:comments)) @@ -177,13 +177,13 @@ def test_add_joins_with_sub_relationship_and_filters assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(PostResource._relationship(:comments))) - assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(CommentResource._relationship(:author))) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(PersonResource._relationship(:comments))) end def test_polymorphic_join_belongs_to_just_source - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new( + join_manager = JSONAPI::ActiveRelation::JoinManager.new( resource_klass: PictureResource, source_relationship: PictureResource._relationship(:imageable) ) @@ -200,7 +200,7 @@ def test_polymorphic_join_belongs_to_just_source def test_polymorphic_join_belongs_to_filter filters = {'imageable' => ['Foo']} - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PictureResource, filters: filters) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PictureResource, filters: filters) records = PictureResource.records({}) records = join_manager.join(records, {}) @@ -217,7 +217,7 @@ def test_polymorphic_join_belongs_to_filter_on_resource } relationships = %w(imageable file_properties) - join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PictureResource, + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PictureResource, filters: filters, relationships: relationships) diff --git a/test/unit/active_relation_resource_finder/join_manager_v10_test.rb b/test/unit/active_relation_resource_finder/join_manager_v10_test.rb index bae45ecda..d0ec9a292 100644 --- a/test/unit/active_relation_resource_finder/join_manager_v10_test.rb +++ b/test/unit/active_relation_resource_finder/join_manager_v10_test.rb @@ -9,7 +9,7 @@ def test_no_added_joins records = join_manager.join(records, {}) assert_equal 'SELECT "posts".* FROM "posts"', sql_for_compare(records.to_sql) - assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details.except!(:join_options)) end def test_add_single_join @@ -18,8 +18,22 @@ def test_add_single_join records = PostResource.records({}) records = join_manager.join(records, {}) assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) - assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details.except!(:join_options)) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags)).except!(:join_options)) + end + + def test_joins_have_join_options + filters = {'tags' => ['1']} + join_manager = JSONAPI::ActiveRelation::JoinManagerV10.new(resource_klass: PostResource, filters: filters) + records = PostResource.records({}) + records = join_manager.join(records, {}) + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) + + source_join_options = join_manager.source_join_details[:join_options] + assert_array_equals [:relationship, :relationship_details, :related_resource_klass], source_join_options.keys + + relationship_join_options = join_manager.join_details_by_relationship(PostResource._relationship(:tags))[:join_options] + assert_array_equals [:relationship, :relationship_details, :related_resource_klass], relationship_join_options.keys end def test_add_single_sort_join @@ -29,8 +43,8 @@ def test_add_single_sort_join records = join_manager.join(records, {}) assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) - assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details.except!(:join_options)) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags)).except!(:join_options)) end def test_add_single_sort_and_filter_join @@ -40,8 +54,8 @@ def test_add_single_sort_and_filter_join records = PostResource.records({}) records = join_manager.join(records, {}) assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', sql_for_compare(records.to_sql) - assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details.except!(:join_options)) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags)).except!(:join_options)) end def test_add_sibling_joins @@ -55,9 +69,9 @@ def test_add_sibling_joins records = join_manager.join(records, {}) assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id"', sql_for_compare(records.to_sql) - assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) - assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:author))) + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details.except!(:join_options)) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags)).except!(:join_options)) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:author)).except!(:join_options)) end @@ -68,7 +82,7 @@ def test_add_joins_source_relationship records = join_manager.join(records, {}) assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id"', sql_for_compare(records.to_sql) - assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details.except!(:join_options)) end @@ -82,7 +96,7 @@ def test_add_joins_source_relationship_with_custom_apply assert_equal sql, sql_for_compare(records.to_sql) - assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details.except!(:join_options)) end def test_add_nested_scoped_joins @@ -96,11 +110,11 @@ def test_add_nested_scoped_joins records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) - assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) - assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) - assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author))) + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details.except!(:join_options)) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments)).except!(:join_options)) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author)).except!(:join_options)) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags)).except!(:join_options)) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author)).except!(:join_options)) # Now test with different order for the filters filters = { @@ -113,11 +127,11 @@ def test_add_nested_scoped_joins records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) - assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) - assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) - assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author))) + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details.except!(:join_options)) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments)).except!(:join_options)) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author)).except!(:join_options)) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags)).except!(:join_options)) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author)).except!(:join_options)) end def test_add_nested_joins_with_fields @@ -131,11 +145,11 @@ def test_add_nested_joins_with_fields records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) - assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) - assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) - assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author))) + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details.except!(:join_options)) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments)).except!(:join_options)) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author)).except!(:join_options)) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags)).except!(:join_options)) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author)).except!(:join_options)) end def test_add_joins_with_sub_relationship @@ -146,10 +160,10 @@ def test_add_joins_with_sub_relationship records = Api::V10::PostResource.records({}) records = join_manager.join(records, {}) - assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) - assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) - assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PersonResource._relationship(:comments))) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details.except!(:join_options)) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments)).except!(:join_options)) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags)).except!(:join_options)) + assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PersonResource._relationship(:comments)).except!(:join_options)) end def test_add_joins_with_sub_relationship_and_filters @@ -167,11 +181,11 @@ def test_add_joins_with_sub_relationship_and_filters records = PostResource.records({}) records = join_manager.join(records, {}) - assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) - assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(PostResource._relationship(:comments))) - assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(CommentResource._relationship(:author))) - assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(CommentResource._relationship(:tags))) - assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(PersonResource._relationship(:comments))) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details.except!(:join_options)) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(PostResource._relationship(:comments)).except!(:join_options)) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(CommentResource._relationship(:author)).except!(:join_options)) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(CommentResource._relationship(:tags)).except!(:join_options)) + assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(PersonResource._relationship(:comments)).except!(:join_options)) end def test_polymorphic_join_belongs_to_just_source @@ -182,10 +196,10 @@ def test_polymorphic_join_belongs_to_just_source records = join_manager.join(records, {}) # assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\'', sql_for_compare(records.to_sql) - assert_hash_equals({alias: 'products', join_type: :left}, join_manager.source_join_details('products')) - assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.source_join_details('documents')) - assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) - assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.source_join_details('products').except!(:join_options)) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.source_join_details('documents').except!(:join_options)) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products').except!(:join_options)) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents').except!(:join_options)) end def test_polymorphic_join_belongs_to_filter @@ -196,9 +210,9 @@ def test_polymorphic_join_belongs_to_filter records = join_manager.join(records, {}) # assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\'', sql_for_compare(records.to_sql) - assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details) - assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) - assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) + assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details.except!(:join_options)) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products').except!(:join_options)) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents').except!(:join_options)) end def test_polymorphic_join_belongs_to_filter_on_resource @@ -214,9 +228,9 @@ def test_polymorphic_join_belongs_to_filter_on_resource records = PictureResource.records({}) records = join_manager.join(records, {}) - assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details) - assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) - assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) - assert_hash_equals({alias: 'file_properties', join_type: :left}, join_manager.join_details_by_relationship(PictureResource._relationship(:file_properties))) + assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details.except!(:join_options)) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products').except!(:join_options)) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents').except!(:join_options)) + assert_hash_equals({alias: 'file_properties', join_type: :left}, join_manager.join_details_by_relationship(PictureResource._relationship(:file_properties)).except!(:join_options)) end end From f00cb0598ef42d6549e9867b352e314ccc4a1df9 Mon Sep 17 00:00:00 2001 From: lgebhardt Date: Tue, 26 Sep 2023 10:45:33 -0400 Subject: [PATCH 10/34] Bump jsonapi-resources to 0.11.0.beta2 --- lib/jsonapi/resources/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index 66235c030..a457ade1d 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -2,6 +2,6 @@ module JSONAPI module Resources - VERSION = '0.11.0.beta1' + VERSION = '0.11.0.beta2' end end From fdbf26eafd480eb045482bb60365ff46e914ff9d Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Fri, 27 Oct 2023 12:58:29 -0500 Subject: [PATCH 11/34] fix: more reliable check of module is included (#1418) handle gems like GraphQL which override `include?` ``` rake aborted! ArgumentError: wrong number of arguments (given 1, expected 3) gems/graphql-2.0.13/lib/graphql/schema/directive.rb:58:in `include?' ``` --- lib/tasks/check_upgrade.rake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tasks/check_upgrade.rake b/lib/tasks/check_upgrade.rake index 869f04e2b..41cb8e0f9 100644 --- a/lib/tasks/check_upgrade.rake +++ b/lib/tasks/check_upgrade.rake @@ -9,7 +9,7 @@ namespace :jsonapi do task :check_upgrade => :environment do Rails.application.eager_load! - resource_klasses = ObjectSpace.each_object(Class).select { |klass| klass.include?(JSONAPI::ResourceCommon)} + resource_klasses = ObjectSpace.each_object(Class).select { |klass| klass.included_modules.include?(JSONAPI::ResourceCommon)} puts "Checking #{resource_klasses.count} resources" From ba643acfd073a3b2d2a3545987f829dc1b44fa45 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 1 Nov 2023 10:21:06 -0400 Subject: [PATCH 12/34] Fix tests for V0.11 and Rails 7.1 (#1420) * Cleanup table definitions for Rails 7.1 * Test helper move require 'rails/test_help' * Test helper add `config.hosts` * Update test matrix to add rails 7.1 and remove ruby 2.6 Note: ruby 2.7 is also EOL, but I'm choosing to continue testing 2.7 for now --- .github/workflows/ruby.yml | 4 +--- test/fixtures/active_record.rb | 10 +++++----- test/test_helper.rb | 5 ++++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 7534b022a..858c61294 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -42,8 +42,8 @@ jobs: - '3.1' - '3.0' - '2.7' - - '2.6' rails: + - '7.1' - '7.0' - '6.1' - '6.0' @@ -60,8 +60,6 @@ jobs: rails: '5.1' - ruby: '3.0' rails: '6.0' - - ruby: '2.6' - rails: '7.0' env: RAILS_VERSION: ${{ matrix.rails }} DATABASE_URL: ${{ matrix.database_url }} diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 2fc1ae7c4..3c692426a 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -52,7 +52,7 @@ end create_table :posts, force: true do |t| - t.string :title, length: 255 + t.string :title, limit: 255 t.text :body t.integer :author_id t.integer :parent_post_id @@ -311,8 +311,8 @@ create_table :things, force: true do |t| t.string :name - t.references :user - t.references :box + t.belongs_to :user + t.belongs_to :box t.timestamps null: false end @@ -324,8 +324,8 @@ create_table :related_things, force: true do |t| t.string :name - t.references :from, references: :thing - t.references :to, references: :thing + t.belongs_to :from + t.belongs_to :to t.timestamps null: false end diff --git a/test/test_helper.rb b/test/test_helper.rb index c43446c0c..74f5c400b 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -23,7 +23,6 @@ ENV['DATABASE_URL'] ||= "sqlite3:test_db" require 'active_record/railtie' -require 'rails/test_help' require 'minitest/mock' require 'jsonapi-resources' require 'pry' @@ -65,8 +64,12 @@ class TestApp < Rails::Application if Rails::VERSION::MAJOR == 5 && Rails::VERSION::MINOR == 2 config.active_record.sqlite3.represent_boolean_as_integer = true end + + config.hosts << "www.example.com" end +require 'rails/test_help' + DatabaseCleaner.allow_remote_database_url = true DatabaseCleaner.strategy = :transaction From c36af713d668279a563428954a9976e51d664f89 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 16 Nov 2023 09:30:04 -0500 Subject: [PATCH 13/34] =?UTF-8?q?Namespace=20references=20to=20Rails=20usi?= =?UTF-8?q?ng=20`::Rails`=20to=20avoid=20conflicts=20with=E2=80=A6=20(#142?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Namespace references to Rails using `::Rails` to avoid conflicts with other gems * Use uppercase JSONAPI module name --- lib/generators/jsonapi/controller_generator.rb | 4 ++-- lib/generators/jsonapi/resource_generator.rb | 2 +- lib/jsonapi-resources.rb | 2 +- .../adapters/join_left_active_record_adapter.rb | 2 +- lib/jsonapi/active_relation_retrieval.rb | 4 ++-- lib/jsonapi/active_relation_retrieval_v09.rb | 2 +- lib/jsonapi/active_relation_retrieval_v10.rb | 4 ++-- lib/jsonapi/acts_as_resource_controller.rb | 8 ++++---- lib/jsonapi/configuration.rb | 4 ++-- lib/jsonapi/exceptions.rb | 2 +- lib/jsonapi/resources/railtie.rb | 2 +- lib/tasks/check_upgrade.rake | 2 +- test/lib/generators/jsonapi/controller_generator_test.rb | 2 +- test/test_helper.rb | 4 ++-- 14 files changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/generators/jsonapi/controller_generator.rb b/lib/generators/jsonapi/controller_generator.rb index 3ebdece11..5747ab2eb 100644 --- a/lib/generators/jsonapi/controller_generator.rb +++ b/lib/generators/jsonapi/controller_generator.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -module Jsonapi - class ControllerGenerator < Rails::Generators::NamedBase +module JSONAPI + class ControllerGenerator < ::Rails::Generators::NamedBase source_root File.expand_path('../templates', __FILE__) def create_resource diff --git a/lib/generators/jsonapi/resource_generator.rb b/lib/generators/jsonapi/resource_generator.rb index 34957d1e2..847f7e0ba 100644 --- a/lib/generators/jsonapi/resource_generator.rb +++ b/lib/generators/jsonapi/resource_generator.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Jsonapi - class ResourceGenerator < Rails::Generators::NamedBase + class ResourceGenerator < ::Rails::Generators::NamedBase source_root File.expand_path('../templates', __FILE__) def create_resource diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index bf3799685..5d46099ab 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -12,7 +12,7 @@ require 'jsonapi/cached_response_fragment' require 'jsonapi/response_document' require 'jsonapi/acts_as_resource_controller' -if Rails::VERSION::MAJOR >= 6 +if ::Rails::VERSION::MAJOR >= 6 ActiveSupport.on_load(:action_controller_base) do require 'jsonapi/resource_controller' end diff --git a/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb b/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb index 2bac4569c..2d5702575 100644 --- a/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb +++ b/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb @@ -9,7 +9,7 @@ module JoinLeftActiveRecordAdapter # example Post.joins(:comments).joins_left(comments: :author) will join the comments table twice, # once inner and once left in 5.2, but only as inner in earlier versions. def joins_left(*columns) - if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) + if ::Rails::VERSION::MAJOR >= 6 || (::Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) left_joins(columns) else join_dependency = ActiveRecord::Associations::JoinDependency.new(self, columns, []) diff --git a/lib/jsonapi/active_relation_retrieval.rb b/lib/jsonapi/active_relation_retrieval.rb index 1942d85b3..1e1a72fb2 100644 --- a/lib/jsonapi/active_relation_retrieval.rb +++ b/lib/jsonapi/active_relation_retrieval.rb @@ -722,7 +722,7 @@ def apply_single_sort(records, field, direction, options) # Assumes ActiveRecord's counting. Override if you need a different counting method def count_records(records) - if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR == 5 && ActiveRecord::VERSION::MINOR >= 1) + if ::Rails::VERSION::MAJOR >= 6 || (::Rails::VERSION::MAJOR == 5 && ActiveRecord::VERSION::MINOR >= 1) records.count(:all) else records.count @@ -870,7 +870,7 @@ def apply_filter(records, filter, value, options = {}) end def warn_about_unused_methods - if Rails.env.development? + if ::Rails.env.development? if !caching? && implements_class_method?(:records_for_populate) warn "#{self}: The `records_for_populate` method is not used when caching is disabled." end diff --git a/lib/jsonapi/active_relation_retrieval_v09.rb b/lib/jsonapi/active_relation_retrieval_v09.rb index f3d9ecf3d..0b9b38cd7 100644 --- a/lib/jsonapi/active_relation_retrieval_v09.rb +++ b/lib/jsonapi/active_relation_retrieval_v09.rb @@ -700,7 +700,7 @@ def join_relationship(records:, relationship:, resource_type: nil, join_type: :i end def warn_about_unused_methods - if Rails.env.development? + if ::Rails.env.development? if !caching? && implements_class_method?(:records_for_populate) warn "#{self}: The `records_for_populate` method is not used when caching is disabled." end diff --git a/lib/jsonapi/active_relation_retrieval_v10.rb b/lib/jsonapi/active_relation_retrieval_v10.rb index 9464af768..bf8466f80 100644 --- a/lib/jsonapi/active_relation_retrieval_v10.rb +++ b/lib/jsonapi/active_relation_retrieval_v10.rb @@ -703,7 +703,7 @@ def apply_single_sort(records, field, direction, options) # Assumes ActiveRecord's counting. Override if you need a different counting method def count_records(records) - if (Rails::VERSION::MAJOR == 5 && ActiveRecord::VERSION::MINOR >= 1) || Rails::VERSION::MAJOR >= 6 + if (::Rails::VERSION::MAJOR == 5 && ActiveRecord::VERSION::MINOR >= 1) || ::Rails::VERSION::MAJOR >= 6 records.count(:all) else records.count @@ -847,7 +847,7 @@ def apply_filter(records, filter, value, options = {}) end def warn_about_unused_methods - if Rails.env.development? + if ::Rails.env.development? if !caching? && implements_class_method?(:records_for_populate) warn "#{self}: The `records_for_populate` method is not used when caching is disabled." end diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index e448fa0ea..86b39e6ca 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -160,7 +160,7 @@ def resource_serializer_klass end def base_url - @base_url ||= "#{request.protocol}#{request.host_with_port}#{Rails.application.config.relative_url_root}" + @base_url ||= "#{request.protocol}#{request.host_with_port}#{::Rails.application.config.relative_url_root}" end def resource_klass_name @@ -286,7 +286,7 @@ def handle_exceptions(e) request.env['action_dispatch.exception'] ||= e internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) - Rails.logger.error { "Internal Server Error: #{e.message} #{e.backtrace.join("\n")}" } + ::Rails.logger.error { "Internal Server Error: #{e.message} #{e.backtrace.join("\n")}" } errors = internal_server_error.errors end end @@ -298,7 +298,7 @@ def safe_run_callback(callback, error) begin callback.call(error) rescue => e - Rails.logger.error { "Error in error handling callback: #{e.message} #{e.backtrace.join("\n")}" } + ::Rails.logger.error { "Error in error handling callback: #{e.message} #{e.backtrace.join("\n")}" } internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) return JSONAPI::ErrorsOperationResult.new(internal_server_error.errors[0].code, internal_server_error.errors) end @@ -324,7 +324,7 @@ def on_server_error(*args, &callback_block) if self.respond_to? method send(method, error) else - Rails.logger.warn("#{method} not defined on #{self}, skipping error callback") + ::Rails.logger.warn("#{method} not defined on #{self}, skipping error callback") end end end.compact diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 6d6c7850b..1d2c235e6 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -88,11 +88,11 @@ def initialize # Whether or not to include exception backtraces in JSONAPI error # responses. Defaults to `false` in anything other than development or test. - self.include_backtraces_in_errors = (Rails.env.development? || Rails.env.test?) + self.include_backtraces_in_errors = (::Rails.env.development? || ::Rails.env.test?) # Whether or not to include exception application backtraces in JSONAPI error # responses. Defaults to `false` in anything other than development or test. - self.include_application_backtraces_in_errors = (Rails.env.development? || Rails.env.test?) + self.include_application_backtraces_in_errors = (::Rails.env.development? || ::Rails.env.test?) # List of classes that should not be rescued by the operations processor. # For example, if you use Pundit for authorization, you might diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index e917118cf..6fb8b675f 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -54,7 +54,7 @@ def errors if JSONAPI.configuration.include_application_backtraces_in_errors meta ||= Hash.new meta[:exception] ||= exception.message - meta[:application_backtrace] = exception.backtrace.select{|line| line =~ /#{Rails.root}/} + meta[:application_backtrace] = exception.backtrace.select{|line| line =~ /#{::Rails.root}/} end [create_error_object(code: JSONAPI::INTERNAL_SERVER_ERROR, diff --git a/lib/jsonapi/resources/railtie.rb b/lib/jsonapi/resources/railtie.rb index f94edfa2a..8e72eb01d 100644 --- a/lib/jsonapi/resources/railtie.rb +++ b/lib/jsonapi/resources/railtie.rb @@ -2,7 +2,7 @@ module JSONAPI module Resources - class Railtie < Rails::Railtie + class Railtie < ::Rails::Railtie rake_tasks do load 'tasks/check_upgrade.rake' end diff --git a/lib/tasks/check_upgrade.rake b/lib/tasks/check_upgrade.rake index 41cb8e0f9..89e0536a0 100644 --- a/lib/tasks/check_upgrade.rake +++ b/lib/tasks/check_upgrade.rake @@ -7,7 +7,7 @@ namespace :jsonapi do namespace :resources do desc 'Checks application for orphaned overrides' task :check_upgrade => :environment do - Rails.application.eager_load! + ::Rails.application.eager_load! resource_klasses = ObjectSpace.each_object(Class).select { |klass| klass.included_modules.include?(JSONAPI::ResourceCommon)} diff --git a/test/lib/generators/jsonapi/controller_generator_test.rb b/test/lib/generators/jsonapi/controller_generator_test.rb index faed0637c..5a54d2fbd 100644 --- a/test/lib/generators/jsonapi/controller_generator_test.rb +++ b/test/lib/generators/jsonapi/controller_generator_test.rb @@ -1,7 +1,7 @@ require File.expand_path('../../../../test_helper', __FILE__) require 'generators/jsonapi/controller_generator' -module Jsonapi +module JSONAPI class ControllerGeneratorTest < Rails::Generators::TestCase tests ControllerGenerator destination Rails.root.join('../controllers') diff --git a/test/test_helper.rb b/test/test_helper.rb index 74f5c400b..98b7672c9 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -45,7 +45,7 @@ puts "Testing With RAILS VERSION #{Rails.version}" -class TestApp < Rails::Application +class TestApp < ::Rails::Application config.eager_load = false config.root = File.dirname(__FILE__) config.session_store :cookie_store, key: 'session' @@ -61,7 +61,7 @@ class TestApp < Rails::Application config.active_support.halt_callback_chains_on_return_false = false config.active_record.time_zone_aware_types = [:time, :datetime] config.active_record.belongs_to_required_by_default = false - if Rails::VERSION::MAJOR == 5 && Rails::VERSION::MINOR == 2 + if ::Rails::VERSION::MAJOR == 5 && ::Rails::VERSION::MINOR == 2 config.active_record.sqlite3.represent_boolean_as_integer = true end From 0b642c702bce569f9a36e49fce7733bfc56b6747 Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Wed, 10 Jan 2024 14:38:51 -0600 Subject: [PATCH 14/34] chore: remove sorted_set dependency (#1423) --- jsonapi-resources.gemspec | 1 - lib/jsonapi/resource_fragment.rb | 2 +- lib/jsonapi/resource_set.rb | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index 2f2044aa8..65372c9fb 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -31,5 +31,4 @@ Gem::Specification.new do |spec| spec.add_dependency 'activerecord', '>= 5.1' spec.add_dependency 'railties', '>= 5.1' spec.add_dependency 'concurrent-ruby' - spec.add_dependency 'sorted_set' end diff --git a/lib/jsonapi/resource_fragment.rb b/lib/jsonapi/resource_fragment.rb index fb9237142..c354f9aa0 100644 --- a/lib/jsonapi/resource_fragment.rb +++ b/lib/jsonapi/resource_fragment.rb @@ -25,7 +25,7 @@ def initialize(identity, resource: nil, cache: nil, primary: false) @primary = primary @related = {} - @related_from = SortedSet.new + @related_from = Set.new end def initialize_related(relationship_name) diff --git a/lib/jsonapi/resource_set.rb b/lib/jsonapi/resource_set.rb index 894d85577..f4d6186f3 100644 --- a/lib/jsonapi/resource_set.rb +++ b/lib/jsonapi/resource_set.rb @@ -180,7 +180,7 @@ def flatten_resource_tree(resource_tree, flattened_tree = {}) flattened_tree[resource_klass][id][:resource] ||= fragment.resource if fragment.resource fragment.related.try(:each_pair) do |relationship_name, related_rids| - flattened_tree[resource_klass][id][:relationships][relationship_name] ||= SortedSet.new + flattened_tree[resource_klass][id][:relationships][relationship_name] ||= Set.new flattened_tree[resource_klass][id][:relationships][relationship_name].merge(related_rids) end end From 18e150c49752f0263916285f4fbc0fbbde01de77 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Thu, 11 Jan 2024 16:25:05 -0500 Subject: [PATCH 15/34] Make SortedSet for identity arrays optional (#1427) * Make SortedSet for identity arrays optional * Fix tests to use sort_related_identities_by_primary_key option override * Keep SortedSet as a development dependency, unless required * Remove sorted_set dependency * Add better messaging about using SortedSet * Clarify setting sort_criteria for includes vs. related resources --- jsonapi-resources.gemspec | 1 + lib/jsonapi/active_relation_retrieval.rb | 15 +++++++++++---- lib/jsonapi/configuration.rb | 12 +++++++++++- lib/jsonapi/resource_fragment.rb | 2 +- lib/jsonapi/resource_set.rb | 2 +- test/test_helper.rb | 3 +++ 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index 65372c9fb..f6b2f8af4 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -28,6 +28,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'concurrent-ruby-ext' spec.add_development_dependency 'database_cleaner' spec.add_development_dependency 'hashie' + spec.add_development_dependency 'sorted_set' spec.add_dependency 'activerecord', '>= 5.1' spec.add_dependency 'railties', '>= 5.1' spec.add_dependency 'concurrent-ruby' diff --git a/lib/jsonapi/active_relation_retrieval.rb b/lib/jsonapi/active_relation_retrieval.rb index 1e1a72fb2..f331bd59f 100644 --- a/lib/jsonapi/active_relation_retrieval.rb +++ b/lib/jsonapi/active_relation_retrieval.rb @@ -271,7 +271,7 @@ def find_related_fragments(source_fragment, relationship, options = {}) source_resource_klasses.each do |resource_klass| inverse_direct_relationship = _relationship(resource_klass._type.to_s.singularize) - fragments.merge!(resource_klass.find_related_fragments_from_inverse([source_fragment], inverse_direct_relationship, options, true)) + fragments.merge!(resource_klass.find_related_fragments_from_inverse([source_fragment], inverse_direct_relationship, options, false)) end fragments else @@ -317,9 +317,16 @@ def find_related_fragments_from_inverse(source, source_relationship, options, co linkage_relationships = to_one_relationships_for_linkage(include_directives[:include_related]) sort_criteria = [] - options[:sort_criteria].try(:each) do |sort| - field = sort[:field].to_s == 'id' ? _primary_key : sort[:field] - sort_criteria << { field: field, direction: sort[:direction] } + + # Do not sort the related_fragments. This can be keyed off `connect_source_identity` to indicate whether this + # is a related resource primary step vs. an include step. + sort_related_fragments = !connect_source_identity + + if sort_related_fragments + options[:sort_criteria].try(:each) do |sort| + field = sort[:field].to_s == 'id' ? _primary_key : sort[:field] + sort_criteria << { field: field, direction: sort[:direction] } + end end join_manager = ActiveRelation::JoinManager.new(resource_klass: self, diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 1d2c235e6..b9f4b772c 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -43,7 +43,8 @@ class Configuration :resource_cache_usage_report_function, :default_exclude_links, :default_resource_retrieval_strategy, - :use_related_resource_records_for_joins + :use_related_resource_records_for_joins, + :related_identities_set def initialize #:underscored_key, :camelized_key, :dasherized_key, or custom @@ -182,6 +183,13 @@ def initialize # This setting allows included resources to account for permission scopes. It can be overridden explicitly per # relationship. Furthermore, specifying a `relation_name` on a relationship will cause this setting to be ignored. self.use_related_resource_records_for_joins = true + + # Collect the include keys into a Set or a SortedSet. SortedSet carries a small performance cost in the rails app + # but produces consistent and more human navigable result sets. + # To use SortedSet be sure to add `sorted_set` to your Gemfile and the following two lines to your JR initializer: + # require 'sorted_set' + # config.related_identities_set = SortedSet + self.related_identities_set = Set end def cache_formatters=(bool) @@ -327,6 +335,8 @@ def allow_include=(allow_include) attr_writer :default_resource_retrieval_strategy attr_writer :use_related_resource_records_for_joins + + attr_writer :related_identities_set end class << self diff --git a/lib/jsonapi/resource_fragment.rb b/lib/jsonapi/resource_fragment.rb index c354f9aa0..0149f1eee 100644 --- a/lib/jsonapi/resource_fragment.rb +++ b/lib/jsonapi/resource_fragment.rb @@ -25,7 +25,7 @@ def initialize(identity, resource: nil, cache: nil, primary: false) @primary = primary @related = {} - @related_from = Set.new + @related_from = JSONAPI.configuration.related_identities_set.new end def initialize_related(relationship_name) diff --git a/lib/jsonapi/resource_set.rb b/lib/jsonapi/resource_set.rb index f4d6186f3..e5846994d 100644 --- a/lib/jsonapi/resource_set.rb +++ b/lib/jsonapi/resource_set.rb @@ -180,7 +180,7 @@ def flatten_resource_tree(resource_tree, flattened_tree = {}) flattened_tree[resource_klass][id][:resource] ||= fragment.resource if fragment.resource fragment.related.try(:each_pair) do |relationship_name, related_rids| - flattened_tree[resource_klass][id][:relationships][relationship_name] ||= Set.new + flattened_tree[resource_klass][id][:relationships][relationship_name] ||= JSONAPI.configuration.related_identities_set.new flattened_tree[resource_klass][id][:relationships][relationship_name].merge(related_rids) end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 98b7672c9..33554bb86 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -39,6 +39,9 @@ JSONAPI.configure do |config| config.json_key_format = :camelized_key + + require 'sorted_set' + config.related_identities_set = SortedSet end ActiveSupport::Deprecation.silenced = true From c61110f6308d5640a089d24277020e25a6b69711 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Sat, 13 Jan 2024 11:06:19 -0500 Subject: [PATCH 16/34] Store the resource_klass and id in an array for efficiency (#1428) Removes the need to allocate a new array for every comparison --- lib/jsonapi/resource_identity.rb | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/jsonapi/resource_identity.rb b/lib/jsonapi/resource_identity.rb index 74fae1aa9..9466ba0c0 100644 --- a/lib/jsonapi/resource_identity.rb +++ b/lib/jsonapi/resource_identity.rb @@ -13,11 +13,17 @@ module JSONAPI # rid = ResourceIdentity.new(PostResource, 12) # class ResourceIdentity - attr_reader :resource_klass, :id - + # Store the identity parts as an array to avoid allocating a new array for the hash method to work on def initialize(resource_klass, id) - @resource_klass = resource_klass - @id = id + @identity_parts = [resource_klass, id] + end + + def resource_klass + @identity_parts[0] + end + + def id + @identity_parts[1] end def ==(other) @@ -27,11 +33,11 @@ def ==(other) end def eql?(other) - other.is_a?(ResourceIdentity) && other.resource_klass == @resource_klass && other.id == @id + hash == other.hash end def hash - [@resource_klass, @id].hash + @identity_parts.hash end def <=>(other_identity) From 52da65e05d6f774ac275b64ddf947bf8d48e9bf2 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 16 Jan 2024 07:50:53 -0500 Subject: [PATCH 17/34] Rework ResourceIdentity <=> operator (#1430) add tests for ResourceIdentity, including that comparison does not allocate memory --- jsonapi-resources.gemspec | 1 + lib/jsonapi/resource_identity.rb | 13 ++++- test/unit/resource/resource_identity_test.rb | 58 ++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 test/unit/resource/resource_identity_test.rb diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index f6b2f8af4..1a0aa5610 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -29,6 +29,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'database_cleaner' spec.add_development_dependency 'hashie' spec.add_development_dependency 'sorted_set' + spec.add_development_dependency 'memory_profiler' spec.add_dependency 'activerecord', '>= 5.1' spec.add_dependency 'railties', '>= 5.1' spec.add_dependency 'concurrent-ruby' diff --git a/lib/jsonapi/resource_identity.rb b/lib/jsonapi/resource_identity.rb index 9466ba0c0..58936d95e 100644 --- a/lib/jsonapi/resource_identity.rb +++ b/lib/jsonapi/resource_identity.rb @@ -13,6 +13,8 @@ module JSONAPI # rid = ResourceIdentity.new(PostResource, 12) # class ResourceIdentity + include Comparable + # Store the identity parts as an array to avoid allocating a new array for the hash method to work on def initialize(resource_klass, id) @identity_parts = [resource_klass, id] @@ -41,7 +43,16 @@ def hash end def <=>(other_identity) - self.id <=> other_identity.id + return nil unless other_identity.is_a?(ResourceIdentity) + + case self.resource_klass.name <=> other_identity.resource_klass.name + when -1 + -1 + when 1 + 1 + else + self.id <=> other_identity.id + end end # Creates a string representation of the identifier. diff --git a/test/unit/resource/resource_identity_test.rb b/test/unit/resource/resource_identity_test.rb new file mode 100644 index 000000000..8d4ee6cfc --- /dev/null +++ b/test/unit/resource/resource_identity_test.rb @@ -0,0 +1,58 @@ +require File.expand_path('../../../test_helper', __FILE__) +require 'memory_profiler' + +class ResourceIdentity < ActiveSupport::TestCase + + def test_can_generate_a_consistent_hash_for_comparison + rid = JSONAPI::ResourceIdentity.new(PostResource, 12) + assert_equal(rid.hash, [PostResource, 12].hash) + end + + def test_equality + rid = JSONAPI::ResourceIdentity.new(PostResource, 12) + rid2 = JSONAPI::ResourceIdentity.new(PostResource, 12) + assert_equal(rid, rid2) # uses == internally + assert rid.eql?(rid2) + end + + def test_inequality + rid = JSONAPI::ResourceIdentity.new(PostResource, 12) + rid2 = JSONAPI::ResourceIdentity.new(PostResource, 13) + refute_equal(rid, rid2) + end + + def test_sorting_by_resource_class_name + rid = JSONAPI::ResourceIdentity.new(CommentResource, 13) + rid2 = JSONAPI::ResourceIdentity.new(PostResource, 13) + rid3 = JSONAPI::ResourceIdentity.new(SectionResource, 13) + assert_equal([rid2, rid3, rid].sort, [rid, rid2, rid3]) + end + + def test_sorting_by_id_secondarily + rid = JSONAPI::ResourceIdentity.new(PostResource, 12) + rid2 = JSONAPI::ResourceIdentity.new(PostResource, 13) + rid3 = JSONAPI::ResourceIdentity.new(PostResource, 14) + + assert_equal([rid2, rid3, rid].sort, [rid, rid2, rid3]) + end + + def test_to_s + rid = JSONAPI::ResourceIdentity.new(PostResource, 12) + assert_equal(rid.to_s, 'PostResource:12') + end + + def test_comparisons_return_nil_for_non_resource_identity + rid = JSONAPI::ResourceIdentity.new(PostResource, 13) + rid2 = "PostResource:13" + assert_nil(rid <=> rid2) + end + + def test_comparisons_allocate_no_new_memory + rid = JSONAPI::ResourceIdentity.new(PostResource, 13) + rid2 = JSONAPI::ResourceIdentity.new(PostResource, 13) + allocation_report = MemoryProfiler.report do + rid == rid2 + end + assert_equal 0, allocation_report.total_allocated + end +end From c052d465514f254827ca618ef2a86ed989330480 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Tue, 16 Jan 2024 14:45:09 -0500 Subject: [PATCH 18/34] V0 11 dev performance (#1431) * Reduce number of string allocations in LinkBuilder * Consistently access `include_related` * Remove unused class variable * Cache `id` after retrieving it from the model * Cache `module_path` * Cache resource_klass_for and resource_type_for * Remove no longer used method _setup_relationship * Delete nil values without creating a new object * Rework resource naming for method caches --- lib/jsonapi/link_builder.rb | 14 ++--- lib/jsonapi/resource_common.rb | 95 +++++++++++++++++------------ lib/jsonapi/resource_serializer.rb | 4 +- lib/jsonapi/resource_tree.rb | 5 +- test/controllers/controller_test.rb | 4 ++ 5 files changed, 70 insertions(+), 52 deletions(-) diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index d78f414e1..23b94a4d9 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -49,8 +49,8 @@ def query_link(query_params) def relationships_related_link(source, relationship, query_params = {}) if relationship._routed - url = "#{ self_link(source) }/#{ route_for_relationship(relationship) }" - url = "#{ url }?#{ query_params.to_query }" if query_params.present? + url = +"#{ self_link(source) }/#{ route_for_relationship(relationship) }" + url << "?#{ query_params.to_query }" if query_params.present? url else if JSONAPI.configuration.warn_on_missing_routes && !relationship._warned_missing_route @@ -129,18 +129,14 @@ def resources_path(source_klass) @_resources_path[source_klass] ||= formatted_module_path_from_class(source_klass) + format_route(source_klass._type.to_s) end - def resource_path(source) + def resource_url(source) if source.class.singleton? - resources_path(source.class) + "#{ base_url }#{ engine_mount_point }#{ resources_path(source.class) }" else - "#{resources_path(source.class)}/#{source.id}" + "#{ base_url }#{ engine_mount_point }#{resources_path(source.class)}/#{source.id}" end end - def resource_url(source) - "#{ base_url }#{ engine_mount_point }#{ resource_path(source) }" - end - def route_for_relationship(relationship) format_route(relationship.name) end diff --git a/lib/jsonapi/resource_common.rb b/lib/jsonapi/resource_common.rb index 5c10f07be..0cd290516 100644 --- a/lib/jsonapi/resource_common.rb +++ b/lib/jsonapi/resource_common.rb @@ -39,7 +39,7 @@ def _model end def id - _model.public_send(self.class._primary_key) + @id ||= _model.public_send(self.class._primary_key) end def identity @@ -510,7 +510,10 @@ def inherited(subclass) subclass._routed = false subclass._warned_missing_route = false - subclass._clear_cached_attribute_options + subclass._attribute_options_cache = {} + subclass._model_class_to_resource_type_cache = {} + subclass._resource_type_to_class_cache = {} + subclass._clear_fields_cache subclass._resource_retrieval_strategy_loaded = @_resource_retrieval_strategy_loaded @@ -533,15 +536,19 @@ def rebuild_relationships(relationships) end def resource_klass_for(type) + @_resource_type_to_class_cache ||= {} type = type.underscore - type_with_module = type.start_with?(module_path) ? type : module_path + type - resource_name = _resource_name_from_type(type_with_module) - resource = resource_name.safe_constantize if resource_name - if resource.nil? - fail NameError, "JSONAPI: Could not find resource '#{type}'. (Class #{resource_name} not found)" + @_resource_type_to_class_cache.fetch(type) do + type_with_module = type.start_with?(module_path) ? type : module_path + type + + resource_name = _resource_name_from_type(type_with_module) + resource_klass = resource_name.safe_constantize if resource_name + if resource_klass.nil? + fail NameError, "JSONAPI: Could not find resource '#{type}'. (Class #{resource_name} not found)" + end + @_resource_type_to_class_cache[type] = resource_klass end - resource end def resource_klass_for_model(model) @@ -553,17 +560,33 @@ def _resource_name_from_type(type) end def resource_type_for(model) - model_name = model.class.to_s.underscore - if _model_hints[model_name] - _model_hints[model_name] - else - model_name.rpartition('/').last + @_model_class_to_resource_type_cache.fetch(model.class) do + model_name = model.class.name.underscore + + resource_type = if _model_hints[model_name] + _model_hints[model_name] + else + model_name.rpartition('/').last + end + + @_model_class_to_resource_type_cache[model.class] = resource_type end end - attr_accessor :_attributes, :_relationships, :_type, :_model_hints, :_routed, :_warned_missing_route, + attr_accessor :_attributes, + :_relationships, + :_type, + :_model_hints, + :_routed, + :_warned_missing_route, :_resource_retrieval_strategy_loaded - attr_writer :_allowed_filters, :_paginator, :_allowed_sort + + attr_writer :_allowed_filters, + :_paginator, + :_allowed_sort, + :_model_class_to_resource_type_cache, + :_resource_type_to_class_cache, + :_attribute_options_cache def create(context) new(create_model, context) @@ -590,7 +613,7 @@ def attributes(*attrs) end def attribute(attribute_name, options = {}) - _clear_cached_attribute_options + _clear_attribute_options_cache _clear_fields_cache attr = attribute_name.to_sym @@ -903,7 +926,7 @@ def verify_relationship_filter(filter, raw, _context = nil) # quasi private class methods def _attribute_options(attr) - @_cached_attribute_options[attr] ||= default_attribute_options.merge(@_attributes[attr]) + @_attribute_options_cache[attr] ||= default_attribute_options.merge(@_attributes[attr]) end def _attribute_delegated_name(attr) @@ -915,11 +938,11 @@ def _has_attribute?(attr) end def _updatable_attributes - _attributes.map { |key, options| key unless options[:readonly] }.compact + _attributes.map { |key, options| key unless options[:readonly] }.delete_if {|v| v.nil? } end def _updatable_relationships - @_relationships.map { |key, relationship| key unless relationship.readonly? }.compact + @_relationships.map { |key, relationship| key unless relationship.readonly? }.delete_if {|v| v.nil? } end def _relationship(type) @@ -1132,11 +1155,11 @@ def _has_sort?(sorting) end def module_path - if name == 'JSONAPI::Resource' - '' - else - name =~ /::[^:]+\Z/ ? ($`.freeze.gsub('::', '/') + '/').underscore : '' - end + @module_path ||= if name == 'JSONAPI::Resource' + '' + else + name =~ /::[^:]+\Z/ ? ($`.freeze.gsub('::', '/') + '/').underscore : '' + end end def default_sort @@ -1169,18 +1192,6 @@ def _add_relationship(klass, *attrs) end end - def _setup_relationship(klass, *attrs) - _clear_fields_cache - - options = attrs.extract_options! - options[:parent_resource] = self - - relationship_name = attrs[0].to_sym - check_duplicate_relationship_name(relationship_name) - - define_relationship_methods(relationship_name.to_sym, klass, options) - end - # ResourceBuilder methods def define_relationship_methods(relationship_name, relationship_klass, options) relationship = register_relationship( @@ -1214,8 +1225,16 @@ def register_relationship(name, relationship_object) @_relationships[name] = relationship_object end - def _clear_cached_attribute_options - @_cached_attribute_options = {} + def _clear_attribute_options_cache + @_attribute_options_cache&.clear + end + + def _clear_model_to_resource_type_cache + @_model_class_to_resource_type_cache&.clear + end + + def _clear_resource_type_to_klass_cache + @_resource_type_to_class_cache&.clear end def _clear_fields_cache diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index 1e7d51029..6f876ffb7 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -262,7 +262,7 @@ def links_hash(source) if !links.key?('self') && !source.class.exclude_link?(:self) links['self'] = link_builder.self_link(source) end - links.compact + links.delete_if {|k,v| v.nil? } end def custom_links_hash(source) @@ -340,7 +340,7 @@ def default_relationship_links(source, relationship) links = {} links['self'] = self_link(source, relationship) unless relationship.exclude_link?(:self) links['related'] = related_link(source, relationship) unless relationship.exclude_link?(:related) - links.compact + links.delete_if {|k,v| v.nil? } end def to_many_linkage(rids) diff --git a/lib/jsonapi/resource_tree.rb b/lib/jsonapi/resource_tree.rb index 5cbd830ef..1e93d3fbc 100644 --- a/lib/jsonapi/resource_tree.rb +++ b/lib/jsonapi/resource_tree.rb @@ -68,13 +68,13 @@ def add_resource(resource, include_related) private def init_included_relationships(fragment, include_related) - include_related && include_related.each_key do |relationship_name| + include_related&.each_key do |relationship_name| fragment.initialize_related(relationship_name) end end def load_included(resource_klass, source_resource_tree, include_related, options) - include_related.try(:each_key) do |key| + include_related&.each_key do |key| relationship = resource_klass._relationship(key) relationship_name = relationship.name.to_sym @@ -159,7 +159,6 @@ def initialize(parent_relationship, source_resource_tree) @related_resource_trees ||= {} @parent_relationship = parent_relationship - @parent_relationship_name = parent_relationship.name.to_sym @source_resource_tree = source_resource_tree end diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 12e3cbcc1..2fe181d42 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -4028,6 +4028,8 @@ def test_immutable_update_not_supported class Api::V7::ClientsControllerTest < ActionController::TestCase def test_get_namespaced_model_not_matching_resource_using_model_hint + Api::V7::ClientResource._clear_model_to_resource_type_cache + Api::V7::ClientResource._clear_resource_type_to_klass_cache assert_cacheable_get :index assert_response :success assert_equal 'clients', json_response['data'][0]['type'] @@ -4037,6 +4039,8 @@ def test_get_namespaced_model_not_matching_resource_using_model_hint def test_get_namespaced_model_not_matching_resource_not_using_model_hint Api::V7::ClientResource._model_hints.delete('api/v7/customer') + Api::V7::ClientResource._clear_model_to_resource_type_cache + Api::V7::ClientResource._clear_resource_type_to_klass_cache assert_cacheable_get :index assert_response :success assert_equal 'customers', json_response['data'][0]['type'] From 000e5605e056b17ee655f877488924828d34c0a0 Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Wed, 17 Jan 2024 16:38:22 -0600 Subject: [PATCH 19/34] fix: allow multiple resource relation retrieval methods (#1425) * fix: check if relation retrieval in included via included_modules * require 'jsonapi/relation_retrieval' * feat: raise when cannot include different retrieval strategy * test: multiple retrieval strategies --------- Co-authored-by: lgebhardt --- lib/jsonapi-resources.rb | 1 + lib/jsonapi/active_relation_retrieval.rb | 2 + lib/jsonapi/active_relation_retrieval_v09.rb | 2 + lib/jsonapi/active_relation_retrieval_v10.rb | 2 + lib/jsonapi/relation_retrieval.rb | 7 ++ lib/jsonapi/resource_common.rb | 21 +++-- ...active_relation_resource_retrieval_test.rb | 78 +++++++++++++++++++ 7 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 lib/jsonapi/relation_retrieval.rb create mode 100644 test/unit/resource/multilple_active_relation_resource_retrieval_test.rb diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 5d46099ab..04d7908f3 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -3,6 +3,7 @@ require 'jsonapi/resources/railtie' require 'jsonapi/naive_cache' require 'jsonapi/compiled_json' +require 'jsonapi/relation_retrieval' require 'jsonapi/active_relation_retrieval' require 'jsonapi/active_relation_retrieval_v09' require 'jsonapi/active_relation_retrieval_v10' diff --git a/lib/jsonapi/active_relation_retrieval.rb b/lib/jsonapi/active_relation_retrieval.rb index f331bd59f..af4e62d1b 100644 --- a/lib/jsonapi/active_relation_retrieval.rb +++ b/lib/jsonapi/active_relation_retrieval.rb @@ -2,6 +2,8 @@ module JSONAPI module ActiveRelationRetrieval + include ::JSONAPI::RelationRetrieval + def find_related_ids(relationship, options = {}) self.class.find_related_fragments(self, relationship, options).keys.collect { |rid| rid.id } end diff --git a/lib/jsonapi/active_relation_retrieval_v09.rb b/lib/jsonapi/active_relation_retrieval_v09.rb index 0b9b38cd7..0ca023ea3 100644 --- a/lib/jsonapi/active_relation_retrieval_v09.rb +++ b/lib/jsonapi/active_relation_retrieval_v09.rb @@ -2,6 +2,8 @@ module JSONAPI module ActiveRelationRetrievalV09 + include ::JSONAPI::RelationRetrieval + def find_related_ids(relationship, options = {}) self.class.find_related_fragments(self.fragment, relationship, options).keys.collect { |rid| rid.id } end diff --git a/lib/jsonapi/active_relation_retrieval_v10.rb b/lib/jsonapi/active_relation_retrieval_v10.rb index bf8466f80..f48c54c6c 100644 --- a/lib/jsonapi/active_relation_retrieval_v10.rb +++ b/lib/jsonapi/active_relation_retrieval_v10.rb @@ -2,6 +2,8 @@ module JSONAPI module ActiveRelationRetrievalV10 + include ::JSONAPI::RelationRetrieval + def find_related_ids(relationship, options = {}) self.class.find_related_fragments(self, relationship, options).keys.collect { |rid| rid.id } end diff --git a/lib/jsonapi/relation_retrieval.rb b/lib/jsonapi/relation_retrieval.rb new file mode 100644 index 000000000..7c510955e --- /dev/null +++ b/lib/jsonapi/relation_retrieval.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module JSONAPI + module RelationRetrieval + end +end + diff --git a/lib/jsonapi/resource_common.rb b/lib/jsonapi/resource_common.rb index 0cd290516..c4731df18 100644 --- a/lib/jsonapi/resource_common.rb +++ b/lib/jsonapi/resource_common.rb @@ -431,19 +431,24 @@ def find_related_ids(relationship, options = {}) module ClassMethods def resource_retrieval_strategy(module_name = JSONAPI.configuration.default_resource_retrieval_strategy) - if @_resource_retrieval_strategy_loaded - warn "Resource retrieval strategy #{@_resource_retrieval_strategy_loaded} already loaded for #{self.name}" - return - end - module_name = module_name.to_s return if module_name.blank? || module_name == 'self' || module_name == 'none' - class_eval do - resource_retrieval_module = module_name.safe_constantize - raise "Unable to find resource_retrieval_strategy #{module_name}" unless resource_retrieval_module + resource_retrieval_module = module_name.safe_constantize + + raise "Unable to find resource_retrieval_strategy #{module_name}" unless resource_retrieval_module + if included_modules.include?(::JSONAPI::RelationRetrieval) + if _resource_retrieval_strategy_loaded.nil? || module_name == _resource_retrieval_strategy_loaded + warn "Resource retrieval strategy #{module_name} already loaded for #{self.name}" + return + else + fail ArgumentError.new("Resource retrieval strategy #{_resource_retrieval_strategy_loaded} already loaded for #{self.name}. Cannot load #{module_name}") + end + end + + class_eval do include resource_retrieval_module extend "#{module_name}::ClassMethods".safe_constantize @_resource_retrieval_strategy_loaded = module_name diff --git a/test/unit/resource/multilple_active_relation_resource_retrieval_test.rb b/test/unit/resource/multilple_active_relation_resource_retrieval_test.rb new file mode 100644 index 000000000..012924edc --- /dev/null +++ b/test/unit/resource/multilple_active_relation_resource_retrieval_test.rb @@ -0,0 +1,78 @@ +require File.expand_path('../../../test_helper', __FILE__) + +module VX +end + +class MultipleActiveRelationResourceTest < ActiveSupport::TestCase + def setup + end + + def teardown + teardown_test_constant(::VX, :BaseResource) + teardown_test_constant(::VX, :DuplicateSubBaseResource) + teardown_test_constant(::VX, :InvalidSubBaseResource) + teardown_test_constant(::VX, :ValidCustomBaseResource) + end + + def teardown_test_constant(namespace, constant_name) + return unless namespace.const_defined?(constant_name) + namespace.send(:remove_const, constant_name) + rescue NameError + end + + def test_correct_resource_retrieval_strategy + expected = 'JSONAPI::ActiveRelationRetrieval' + default = JSONAPI.configuration.default_resource_retrieval_strategy + assert_equal expected, default + assert_nil JSONAPI::Resource._resource_retrieval_strategy_loaded + + expected = 'JSONAPI::ActiveRelationRetrieval' + assert_silent do + ::VX.module_eval <<~MODULE + class BaseResource < JSONAPI::Resource + abstract + end + MODULE + end + assert_equal expected, VX::BaseResource._resource_retrieval_strategy_loaded + + strategy = 'JSONAPI::ActiveRelationRetrieval' + expected = 'JSONAPI::ActiveRelationRetrieval' + assert_output nil, "Resource retrieval strategy #{expected} already loaded for VX::DuplicateSubBaseResource\n" do + ::VX.module_eval <<~MODULE + class DuplicateSubBaseResource < JSONAPI::Resource + resource_retrieval_strategy '#{strategy}' + abstract + end + MODULE + end + assert_equal expected, VX::DuplicateSubBaseResource._resource_retrieval_strategy_loaded + + strategy = 'JSONAPI::ActiveRelationRetrievalV10' + expected = "Resource retrieval strategy #{default} already loaded for VX::InvalidSubBaseResource. Cannot load #{strategy}" + ex = assert_raises ArgumentError do + ::VX.module_eval <<~MODULE + class InvalidSubBaseResource < JSONAPI::Resource + resource_retrieval_strategy '#{strategy}' + abstract + end + MODULE + end + assert_equal expected, ex.message + + strategy = 'JSONAPI::ActiveRelationRetrievalV10' + expected = 'JSONAPI::ActiveRelationRetrievalV10' + assert_silent do + ::VX.module_eval <<~MODULE + class ValidCustomBaseResource + include JSONAPI::ResourceCommon + root_resource + abstract + immutable + resource_retrieval_strategy '#{strategy}' + end + MODULE + end + assert_equal expected, VX::ValidCustomBaseResource._resource_retrieval_strategy_loaded + end +end From 4d56ce44ef2b6c664b7a53f876e05de714f5d10e Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Fri, 19 Jan 2024 16:35:36 -0600 Subject: [PATCH 20/34] refactor: separate polymorphic functions (#1433) * refactor: lookup polymorphic types only once * refactor: polymorphic lookups to utility module * refactor: separate polymorphic functions * Refactor into PolymorphicTypesLookup --------- Co-authored-by: lgebhardt --- lib/jsonapi-resources.rb | 1 + lib/jsonapi/relationship.rb | 12 +------- lib/jsonapi/resource_common.rb | 12 +------- lib/jsonapi/utils/polymorphic_types_lookup.rb | 30 +++++++++++++++++++ 4 files changed, 33 insertions(+), 22 deletions(-) create mode 100644 lib/jsonapi/utils/polymorphic_types_lookup.rb diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 04d7908f3..e26af1aa9 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'jsonapi/resources/railtie' +require 'jsonapi/utils/polymorphic_types_lookup' require 'jsonapi/naive_cache' require 'jsonapi/compiled_json' require 'jsonapi/relation_retrieval' diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 31a053b29..da7065235 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -87,17 +87,7 @@ def inverse_relationship end def self.polymorphic_types(name) - @poly_hash ||= {}.tap do |hash| - ObjectSpace.each_object do |klass| - next unless Module === klass - if ActiveRecord::Base > klass - klass.reflect_on_all_associations(:has_many).select { |r| r.options[:as] }.each do |reflection| - (hash[reflection.options[:as]] ||= []) << klass.name.underscore - end - end - end - end - @poly_hash[name.to_sym] + ::JSONAPI::Utils::PolymorphicTypesLookup.polymorphic_types(name) end def resource_types diff --git a/lib/jsonapi/resource_common.rb b/lib/jsonapi/resource_common.rb index c4731df18..85c813619 100644 --- a/lib/jsonapi/resource_common.rb +++ b/lib/jsonapi/resource_common.rb @@ -1020,17 +1020,7 @@ def polymorphic(polymorphic = true) end def _polymorphic_types - @poly_hash ||= {}.tap do |hash| - ObjectSpace.each_object do |klass| - next unless Module === klass - if klass < ActiveRecord::Base - klass.reflect_on_all_associations(:has_many).select{|r| r.options[:as] }.each do |reflection| - (hash[reflection.options[:as]] ||= []) << klass.name.underscore - end - end - end - end - @poly_hash[_polymorphic_name.to_sym] + JSONAPI::Utils.polymorphic_types(_polymorphic_name.to_sym) end def _polymorphic_resource_klasses diff --git a/lib/jsonapi/utils/polymorphic_types_lookup.rb b/lib/jsonapi/utils/polymorphic_types_lookup.rb new file mode 100644 index 000000000..2a72673e5 --- /dev/null +++ b/lib/jsonapi/utils/polymorphic_types_lookup.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module JSONAPI + module Utils + module PolymorphicTypesLookup + extend self + + def polymorphic_types(name) + polymorphic_types_lookup[name.to_sym] + end + + def polymorphic_types_lookup + @polymorphic_types_lookup ||= build_polymorphic_types_lookup + end + + def build_polymorphic_types_lookup + {}.tap do |hash| + ObjectSpace.each_object do |klass| + next unless Module === klass + if ActiveRecord::Base > klass + klass.reflect_on_all_associations(:has_many).select { |r| r.options[:as] }.each do |reflection| + (hash[reflection.options[:as]] ||= []) << klass.name.underscore + end + end + end + end + end + end + end +end From 287e8dd620261526b2fe6179b96c9c33baa52a92 Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Mon, 22 Jan 2024 14:13:12 -0600 Subject: [PATCH 21/34] feat: teach JR in tests to parse the response (#1437) * feat: teach JR in tests to parse the response * use response.parsed_body instead of JSON.parse when evaluating responses in tests --------- Co-authored-by: lgebhardt --- lib/jsonapi/resources/railtie.rb | 13 +++++++++++++ test/helpers/functional_helpers.rb | 4 ++-- test/integration/requests/request_test.rb | 10 +++++----- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/lib/jsonapi/resources/railtie.rb b/lib/jsonapi/resources/railtie.rb index 8e72eb01d..43d9f5c3e 100644 --- a/lib/jsonapi/resources/railtie.rb +++ b/lib/jsonapi/resources/railtie.rb @@ -6,6 +6,19 @@ class Railtie < ::Rails::Railtie rake_tasks do load 'tasks/check_upgrade.rake' end + + + initializer "jsonapi_resources.testing", after: :initialize do + next unless Rails.env.test? + # Make response.parsed_body work + ActionDispatch::IntegrationTest.register_encoder :api_json, + param_encoder: ->(params) { + params + }, + response_parser: ->(body) { + JSONAPI::MimeTypes.parser.call(body) + } + end end end end diff --git a/test/helpers/functional_helpers.rb b/test/helpers/functional_helpers.rb index 3d6dc9d34..b3ce85f83 100644 --- a/test/helpers/functional_helpers.rb +++ b/test/helpers/functional_helpers.rb @@ -53,7 +53,7 @@ module FunctionalHelpers # end # def json_response - JSON.parse(@response.body) + @response.parsed_body end end -end \ No newline at end of file +end diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 0d23a08da..b450a95dd 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -73,13 +73,13 @@ def test_post_sessions 'Accept' => JSONAPI::MEDIA_TYPE } assert_jsonapi_response 201 - json_body = JSON.parse(response.body) + json_body = response.parsed_body session_id = json_body["data"]["id"] # Get what we just created get "/sessions/#{session_id}?include=responses" assert_jsonapi_response 200 - json_body = JSON.parse(response.body) + json_body = response.parsed_body assert(json_body.is_a?(Object)); assert(json_body["included"].is_a?(Array)); @@ -87,7 +87,7 @@ def test_post_sessions get "/sessions/#{session_id}?include=responses,responses.paragraph" assert_jsonapi_response 200 - json_body = JSON.parse(response.body) + json_body = response.parsed_body assert_equal("single_textbox", json_body["included"][0]["attributes"]["response_type"]["single_textbox"]); @@ -348,7 +348,7 @@ def test_post_polymorphic_with_has_many_relationship assert_jsonapi_response 201 - body = JSON.parse(response.body) + body = response.parsed_body person = Person.find(body.dig("data", "id")) assert_equal "Reo", person.name @@ -649,7 +649,7 @@ def test_patch_polymorphic_with_has_many_relationship assert_jsonapi_response 200 - body = JSON.parse(response.body) + body = response.parsed_body person = Person.find(body.dig("data", "id")) assert_equal "Reo", person.name From b0137bab916a46844a563d7e1fecc884692e17b2 Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Mon, 22 Jan 2024 14:15:46 -0600 Subject: [PATCH 22/34] chore: address deprecations (#1436) * chore(deprecation): test_fixture= has been deprecated in favor of tests_fixtures= * chore(deprecations): ActiveSupport::Deprecation.silenced ``` ActiveSupport::Deprecation is deprecated and will be removed from Rails (use Rails.application.deprecators.silenced= instead) ``` * chore(deprecation): prefer ActiveSupport.deprectator or our own deprecator. since https://github.com/rails/rails/pull/47354/files --- lib/jsonapi/acts_as_resource_controller.rb | 4 ++-- lib/jsonapi/configuration.rb | 12 ++++++++-- lib/jsonapi/relationship.rb | 2 +- lib/jsonapi/resource_common.rb | 4 ++-- test/integration/requests/request_test.rb | 4 ++-- test/test_helper.rb | 28 ++++++++++++++++++---- test/unit/resource/resource_test.rb | 5 ++-- 7 files changed, 42 insertions(+), 17 deletions(-) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 86b39e6ca..1ef1707c3 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -63,7 +63,7 @@ def index_related_resources def get_related_resource # :nocov: - ActiveSupport::Deprecation.warn "In #{self.class.name} you exposed a `get_related_resource`"\ + JSONAPI.configuration.deprecate "In #{self.class.name} you exposed a `get_related_resource`"\ " action. Please use `show_related_resource` instead." show_related_resource # :nocov: @@ -71,7 +71,7 @@ def get_related_resource def get_related_resources # :nocov: - ActiveSupport::Deprecation.warn "In #{self.class.name} you exposed a `get_related_resources`"\ + JSONAPI.configuration.deprecate "In #{self.class.name} you exposed a `get_related_resources`"\ " action. Please use `index_related_resources` instead." index_related_resources # :nocov: diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index b9f4b772c..e739b722b 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -256,8 +256,16 @@ def exception_class_allowed?(e) @exception_class_allowlist.flatten.any? { |k| e.class.ancestors.map(&:to_s).include?(k.to_s) } end + def deprecate(msg) + if defined?(ActiveSupport.deprecator) + ActiveSupport.deprecator.warn(msg) + else + ActiveSupport::Deprecation.warn(msg) + end + end + def default_processor_klass=(default_processor_klass) - ActiveSupport::Deprecation.warn('`default_processor_klass` has been replaced by `default_processor_klass_name`.') + deprecate('`default_processor_klass` has been replaced by `default_processor_klass_name`.') @default_processor_klass = default_processor_klass end @@ -271,7 +279,7 @@ def default_processor_klass_name=(default_processor_klass_name) end def allow_include=(allow_include) - ActiveSupport::Deprecation.warn('`allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options.') + deprecate('`allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options.') @default_allow_include_to_one = allow_include @default_allow_include_to_many = allow_include end diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index da7065235..9d7ffc46b 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -21,7 +21,7 @@ def initialize(name, options = {}) @polymorphic = options.fetch(:polymorphic, false) == true @polymorphic_types = options[:polymorphic_types] if options[:polymorphic_relations] - ActiveSupport::Deprecation.warn('Use polymorphic_types instead of polymorphic_relations') + JSONAPI.configuration.deprecate('Use polymorphic_types instead of polymorphic_relations') @polymorphic_types ||= options[:polymorphic_relations] end diff --git a/lib/jsonapi/resource_common.rb b/lib/jsonapi/resource_common.rb index 85c813619..b841b732b 100644 --- a/lib/jsonapi/resource_common.rb +++ b/lib/jsonapi/resource_common.rb @@ -626,7 +626,7 @@ def attribute(attribute_name, options = {}) check_reserved_attribute_name(attr) if (attr == :id) && (options[:format].nil?) - ActiveSupport::Deprecation.warn('Id without format is no longer supported. Please remove ids from attributes, or specify a format.') + JSONAPI.configuration.deprecate('Id without format is no longer supported. Please remove ids from attributes, or specify a format.') end check_duplicate_attribute_name(attr) if options[:format].nil? @@ -688,7 +688,7 @@ def has_one(*attrs) end def belongs_to(*attrs) - ActiveSupport::Deprecation.warn "In #{name} you exposed a `has_one` relationship "\ + JSONAPI.configuration.deprecate "In #{name} you exposed a `has_one` relationship "\ " using the `belongs_to` class method. We think `has_one`" \ " is more appropriate. If you know what you're doing," \ " and don't want to see this warning again, override the" \ diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index b450a95dd..362cf95ef 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -1371,7 +1371,7 @@ def test_deprecated_include_parameter_not_allowed end def test_deprecated_include_message - ActiveSupport::Deprecation.silenced = false + silence_deprecations! false original_config = JSONAPI.configuration.dup _out, err = capture_io do eval <<-CODE @@ -1381,7 +1381,7 @@ def test_deprecated_include_message assert_match(/DEPRECATION WARNING: `allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options./, err) ensure JSONAPI.configuration = original_config - ActiveSupport::Deprecation.silenced = true + silence_deprecations! true end diff --git a/test/test_helper.rb b/test/test_helper.rb index 33554bb86..c61f8d2ee 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -44,8 +44,6 @@ config.related_identities_set = SortedSet end -ActiveSupport::Deprecation.silenced = true - puts "Testing With RAILS VERSION #{Rails.version}" class TestApp < ::Rails::Application @@ -71,6 +69,14 @@ class TestApp < ::Rails::Application config.hosts << "www.example.com" end +def silence_deprecations!(bool = true) + if defined?(Rails.application) && Rails.application.respond_to?(:deprecators) + Rails.application.deprecators.silenced = bool + else + ActiveSupport::Deprecation.silenced = bool + end +end + require 'rails/test_help' DatabaseCleaner.allow_remote_database_url = true @@ -466,7 +472,11 @@ def run_in_transaction? true end - self.fixture_path = "#{Rails.root}/fixtures" + if respond_to?(:fixture_paths=) + self.fixture_paths |= ["#{Rails.root}/fixtures"] + else + self.fixture_path = "#{Rails.root}/fixtures" + end fixtures :all def adapter_name @@ -518,7 +528,11 @@ def response_json_for_compare(response) end class ActiveSupport::TestCase - self.fixture_path = "#{Rails.root}/fixtures" + if respond_to?(:fixture_paths=) + self.fixture_paths |= ["#{Rails.root}/fixtures"] + else + self.fixture_path = "#{Rails.root}/fixtures" + end fixtures :all setup do @routes = TestApp.routes @@ -526,7 +540,11 @@ class ActiveSupport::TestCase end class ActionDispatch::IntegrationTest - self.fixture_path = "#{Rails.root}/fixtures" + if respond_to?(:fixture_paths=) + self.fixture_paths |= ["#{Rails.root}/fixtures"] + else + self.fixture_path = "#{Rails.root}/fixtures" + end fixtures :all def assert_jsonapi_response(expected_status, msg = nil) diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 625183095..351dbb997 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -399,8 +399,7 @@ def test_key_type_proc end def test_id_attr_deprecation - - ActiveSupport::Deprecation.silenced = false + silence_deprecations! false _out, err = capture_io do eval <<-CODE class ProblemResource < JSONAPI::Resource @@ -410,7 +409,7 @@ class ProblemResource < JSONAPI::Resource end assert_match /DEPRECATION WARNING: Id without format is no longer supported. Please remove ids from attributes, or specify a format./, err ensure - ActiveSupport::Deprecation.silenced = true + silence_deprecations! true end def test_id_attr_with_format From cc657810430e88927c92faf9fb97565184f2589b Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Mon, 22 Jan 2024 17:56:23 -0600 Subject: [PATCH 23/34] fix: format model polymorphic type from resource object type (#1435) * test: failing request posting sti with polymorphic has one * fix: polymorphic resource assignment * Add polymorphic_type_for method * Favor classify over singularize.camelize --------- Co-authored-by: lgebhardt --- lib/jsonapi/acts_as_resource_controller.rb | 2 +- lib/jsonapi/link_builder.rb | 2 +- lib/jsonapi/relationship.rb | 4 +-- lib/jsonapi/resource_common.rb | 12 ++++--- test/fixtures/active_record.rb | 11 ++++-- test/integration/requests/request_test.rb | 41 ++++++++++++++++++++++ 6 files changed, 62 insertions(+), 10 deletions(-) diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 1ef1707c3..e87a31d6c 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -164,7 +164,7 @@ def base_url end def resource_klass_name - @resource_klass_name ||= "#{self.class.name.underscore.sub(/_controller$/, '').singularize}_resource".camelize + @resource_klass_name ||= "#{self.class.name.underscore.sub(/_controller$/, '').classify}Resource" end def verify_content_type_header diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index 23b94a4d9..63b160fc6 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -92,7 +92,7 @@ def build_engine begin unless scopes.empty? - "#{ scopes.first.to_s.camelize }::Engine".safe_constantize + "#{ scopes.first.to_s.classify }::Engine".safe_constantize end # :nocov: diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 9d7ffc46b..bff416da5 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -155,7 +155,7 @@ class ToOne < Relationship def initialize(name, options = {}) super - @class_name = options.fetch(:class_name, name.to_s.camelize) + @class_name = options.fetch(:class_name, name.to_s.classify) @foreign_key ||= "#{name}_id".to_sym @foreign_key_on = options.fetch(:foreign_key_on, :self) # if parent_resource @@ -231,7 +231,7 @@ class ToMany < Relationship def initialize(name, options = {}) super - @class_name = options.fetch(:class_name, name.to_s.camelize.singularize) + @class_name = options.fetch(:class_name, name.to_s.classify) @foreign_key ||= "#{name.to_s.singularize}_ids".to_sym @reflect = options.fetch(:reflect, true) == true # if parent_resource diff --git a/lib/jsonapi/resource_common.rb b/lib/jsonapi/resource_common.rb index b841b732b..9ec4e0d1c 100644 --- a/lib/jsonapi/resource_common.rb +++ b/lib/jsonapi/resource_common.rb @@ -561,7 +561,7 @@ def resource_klass_for_model(model) end def _resource_name_from_type(type) - "#{type.to_s.underscore.singularize}_resource".camelize + "#{type.to_s.classify}Resource" end def resource_type_for(model) @@ -578,6 +578,10 @@ def resource_type_for(model) end end + def polymorphic_type_for(model_name) + model_name&.to_s&.classify + end + attr_accessor :_attributes, :_relationships, :_type, @@ -1200,12 +1204,12 @@ def define_relationship_methods(relationship_name, relationship_klass, options) def define_foreign_key_setter(relationship) if relationship.polymorphic? define_on_resource "#{relationship.foreign_key}=" do |v| - _model.method("#{relationship.foreign_key}=").call(v[:id]) - _model.public_send("#{relationship.polymorphic_type}=", v[:type]) + _model.public_send("#{relationship.foreign_key}=", v[:id]) + _model.public_send("#{relationship.polymorphic_type}=", self.class.polymorphic_type_for(v[:type])) end else define_on_resource "#{relationship.foreign_key}=" do |value| - _model.method("#{relationship.foreign_key}=").call(value) + _model.public_send("#{relationship.foreign_key}=", value) end end relationship.foreign_key diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 3c692426a..cdcd8a65e 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -275,6 +275,7 @@ t.string :drive_layout t.string :serial_number t.integer :person_id + t.references :imageable, polymorphic: true, index: true t.timestamps null: false end @@ -734,6 +735,9 @@ class Picture < ActiveRecord::Base class Vehicle < ActiveRecord::Base belongs_to :person + belongs_to :imageable, polymorphic: true + # belongs_to :document, -> { where( pictures: { imageable_type: 'Document' } ) }, foreign_key: 'imageable_id' + # belongs_to :product, -> { where( pictures: { imageable_type: 'Product' } ) }, foreign_key: 'imageable_id' end class Car < Vehicle @@ -743,13 +747,13 @@ class Boat < Vehicle end class Document < ActiveRecord::Base - has_many :pictures, as: :imageable + has_many :pictures, as: :imageable # polymorphic belongs_to :author, class_name: 'Person', foreign_key: 'author_id' has_one :file_properties, as: :fileable end class Product < ActiveRecord::Base - has_many :pictures, as: :imageable + has_many :pictures, as: :imageable # polymorphic belongs_to :designer, class_name: 'Person', foreign_key: 'designer_id' has_one :file_properties, as: :fileable end @@ -1338,6 +1342,7 @@ class VehicleResource < JSONAPI::Resource immutable has_one :person + has_one :imageable, polymorphic: true attributes :make, :model, :serial_number end @@ -1917,6 +1922,8 @@ class PreferencesResource < PreferencesResource; end class SectionResource < SectionResource; end class TagResource < TagResource; end class CommentResource < CommentResource; end + class DocumentResource < DocumentResource; end + class ProductResource < ProductResource; end class VehicleResource < VehicleResource; end class CarResource < CarResource; end class BoatResource < BoatResource; end diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 362cf95ef..67e1bf7fe 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -359,6 +359,47 @@ def test_post_polymorphic_with_has_many_relationship assert_equal Car, person.vehicles.fourth.class end + def test_post_sti_polymorphic_with_has_one_relationship + post '/cars', params: + { + 'data' => { + 'type' => 'cars', + 'attributes' => { + 'make' => 'Mazda', + 'model' => 'Miata MX5', + 'drive_layout' => 'Front Engine RWD', + 'serial_number' => '32432adfsfdysua', + }, + 'relationships' => { + 'person' => { + 'data' => { + 'type' => 'people', 'id' => '1001', + } + }, + 'imageable' => { + 'data' => { + 'type' => 'products', 'id' => '1', + } + }, + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_jsonapi_response 201 + + body = response.parsed_body + car = Vehicle.find(body.dig("data", "id")) + + assert_equal "Car", car.type + assert_equal "Mazda", car.make + assert_equal Product, car.imageable.class + assert_equal Person, car.person.class + end + def test_post_polymorphic_invalid_with_wrong_type post '/people', params: { From 7fa3352a0b02c3e9bb73dae1959f5552197f8558 Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Wed, 24 Jan 2024 12:16:53 -0600 Subject: [PATCH 24/34] fix: railtie to use correct load hook (#1438) * chore: fix file typo * fix: railtie to use correct load hook --- lib/jsonapi/resources/railtie.rb | 9 ++++----- ... multiple_active_relation_resource_retrieval_test.rb} | 0 2 files changed, 4 insertions(+), 5 deletions(-) rename test/unit/resource/{multilple_active_relation_resource_retrieval_test.rb => multiple_active_relation_resource_retrieval_test.rb} (100%) diff --git a/lib/jsonapi/resources/railtie.rb b/lib/jsonapi/resources/railtie.rb index 43d9f5c3e..10fb3c054 100644 --- a/lib/jsonapi/resources/railtie.rb +++ b/lib/jsonapi/resources/railtie.rb @@ -7,16 +7,15 @@ class Railtie < ::Rails::Railtie load 'tasks/check_upgrade.rake' end - - initializer "jsonapi_resources.testing", after: :initialize do - next unless Rails.env.test? + # https://guides.rubyonrails.org/v6.0/engines.html#available-hooks + ActiveSupport.on_load(:action_dispatch_integration_test) do # Make response.parsed_body work - ActionDispatch::IntegrationTest.register_encoder :api_json, + ::ActionDispatch::IntegrationTest.register_encoder :api_json, param_encoder: ->(params) { params }, response_parser: ->(body) { - JSONAPI::MimeTypes.parser.call(body) + ::JSONAPI::MimeTypes.parser.call(body) } end end diff --git a/test/unit/resource/multilple_active_relation_resource_retrieval_test.rb b/test/unit/resource/multiple_active_relation_resource_retrieval_test.rb similarity index 100% rename from test/unit/resource/multilple_active_relation_resource_retrieval_test.rb rename to test/unit/resource/multiple_active_relation_resource_retrieval_test.rb From f3bbf0c17b89b754ddc0c4d0f54add83b10eb9c7 Mon Sep 17 00:00:00 2001 From: Benjamin Fleischer Date: Thu, 25 Jan 2024 14:29:19 -0600 Subject: [PATCH 25/34] fix: more flexible polymorphic types lookup (#1434) * fix: more flexible polymorphic types lookup * test: add polymorphic lookup tests they pass on v-11-dev I'm going to look into the existing lookup warnings now ``` [POLYMORPHIC TYPE NOT FOUND] No polymorphic types found for fileable [POLYMORPHIC TYPE] No polymorphic types found for FilePropertiesResource fileable [POLYMORPHIC TYPE NOT FOUND] No polymorphic types found for respondent [POLYMORPHIC TYPE] No polymorphic types found for QuestionResource respondent [POLYMORPHIC TYPE NOT FOUND] No polymorphic types found for respondent [POLYMORPHIC TYPE] No polymorphic types found for AnswerResource respondent [POLYMORPHIC TYPE NOT FOUND] No polymorphic types found for keepable [POLYMORPHIC TYPE] No polymorphic types found for KeeperResource keepable ``` * Revert "test: add polymorphic lookup tests" This reverts commit 0979a7243b6bc816dd2327d3ff23f70209c52dce. * feat: easily clear the lookup * feat: add a descendents strategy * test: polymorphic type lookup * feat: make polymorphic type lookup configurable * feat: clear polymorphic lookup after initialize --- lib/jsonapi/relationship.rb | 2 +- lib/jsonapi/resources/railtie.rb | 4 ++ lib/jsonapi/utils/polymorphic_types_lookup.rb | 71 ++++++++++++++++--- .../utils/polymorphic_types_lookup_test.rb | 35 +++++++++ 4 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 test/unit/utils/polymorphic_types_lookup_test.rb diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index bff416da5..1378d8e7c 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -193,7 +193,7 @@ def polymorphic_type def setup_implicit_relationships_for_polymorphic_types(exclude_linkage_data: true) types = self.class.polymorphic_types(_relation_name) unless types.present? - warn "No polymorphic types found for #{parent_resource.name} #{_relation_name}" + warn "[POLYMORPHIC TYPE] No polymorphic types found for #{parent_resource.name} #{_relation_name}" return end diff --git a/lib/jsonapi/resources/railtie.rb b/lib/jsonapi/resources/railtie.rb index 10fb3c054..02050879b 100644 --- a/lib/jsonapi/resources/railtie.rb +++ b/lib/jsonapi/resources/railtie.rb @@ -18,6 +18,10 @@ class Railtie < ::Rails::Railtie ::JSONAPI::MimeTypes.parser.call(body) } end + + initializer "jsonapi_resources.initialize", after: :initialize do + JSONAPI::Utils::PolymorphicTypesLookup.polymorphic_types_lookup_clear! + end end end end diff --git a/lib/jsonapi/utils/polymorphic_types_lookup.rb b/lib/jsonapi/utils/polymorphic_types_lookup.rb index 2a72673e5..7457410ad 100644 --- a/lib/jsonapi/utils/polymorphic_types_lookup.rb +++ b/lib/jsonapi/utils/polymorphic_types_lookup.rb @@ -5,26 +5,79 @@ module Utils module PolymorphicTypesLookup extend self - def polymorphic_types(name) - polymorphic_types_lookup[name.to_sym] + singleton_class.attr_accessor :build_polymorphic_types_lookup_strategy + self.build_polymorphic_types_lookup_strategy = + :build_polymorphic_types_lookup_from_object_space + + def polymorphic_types(name, rebuild: false) + polymorphic_types_lookup(rebuild: rebuild).fetch(name.to_sym, &handle_polymorphic_type_name_found) + end + + def handle_polymorphic_type_name_found + @handle_polymorphic_type_name_found ||= lambda do |name| + warn "[POLYMORPHIC TYPE NOT FOUND] No polymorphic types found for #{name}" + nil + end end - def polymorphic_types_lookup + def polymorphic_types_lookup(rebuild: false) + polymorphic_types_lookup_clear! if rebuild @polymorphic_types_lookup ||= build_polymorphic_types_lookup end + def polymorphic_types_lookup_clear! + @polymorphic_types_lookup = nil + end + def build_polymorphic_types_lookup - {}.tap do |hash| + public_send(build_polymorphic_types_lookup_strategy) + end + + def build_polymorphic_types_lookup_from_descendants + {}.tap do |lookup| + ActiveRecord::Base + .descendants + .select(&:name) + .reject(&:abstract_class) + .select(&:model_name).map {|klass| + add_polymorphic_types_lookup(klass: klass, lookup: lookup) + } + end + end + + def build_polymorphic_types_lookup_from_object_space + {}.tap do |lookup| ObjectSpace.each_object do |klass| next unless Module === klass - if ActiveRecord::Base > klass - klass.reflect_on_all_associations(:has_many).select { |r| r.options[:as] }.each do |reflection| - (hash[reflection.options[:as]] ||= []) << klass.name.underscore - end - end + next unless ActiveRecord::Base > klass + add_polymorphic_types_lookup(klass: klass, lookup: lookup) end end end + + # TODO(BF): Consider adding the following conditions + # is_active_record_inspectable = true + # is_active_record_inspectable &&= klass.respond_to?(:reflect_on_all_associations, true) + # is_active_record_inspectable &&= format_polymorphic_klass_type(klass).present? + # return unless is_active_record_inspectable + def add_polymorphic_types_lookup(klass:, lookup:) + klass.reflect_on_all_associations(:has_many).select { |r| r.options[:as] }.each do |reflection| + (lookup[reflection.options[:as]] ||= []) << format_polymorphic_klass_type(klass).underscore + end + end + + # TODO(BF): Consider adding the following conditions + # klass.name || + # begin + # klass.model_name.name + # rescue ArgumentError => ex + # # klass.base_class may be nil + # warn "[POLYMORPHIC TYPE] #{__callee__} #{klass} #{ex.inspect}" + # nil + # end + def format_polymorphic_klass_type(klass) + klass.name + end end end end diff --git a/test/unit/utils/polymorphic_types_lookup_test.rb b/test/unit/utils/polymorphic_types_lookup_test.rb new file mode 100644 index 000000000..838ed878b --- /dev/null +++ b/test/unit/utils/polymorphic_types_lookup_test.rb @@ -0,0 +1,35 @@ +require File.expand_path('../../../test_helper', __FILE__) + +class PolymorphicTypesLookupTest < ActiveSupport::TestCase + def setup + JSONAPI::Utils::PolymorphicTypesLookup.polymorphic_types_lookup_clear! + end + + def test_build_polymorphic_types_lookup_from_object_space + expected = { + :imageable=>["product", "document"] + } + actual = JSONAPI::Utils::PolymorphicTypesLookup.build_polymorphic_types_lookup_from_object_space + actual_keys = actual.keys.sort + assert_equal(actual_keys, expected.keys.sort) + actual_keys.each do |actual_key| + actual_values = actual[actual_key].sort + expected_values = expected[actual_key].sort + assert_equal(actual_values, expected_values) + end + end + + def test_build_polymorphic_types_lookup_from_descendants + expected = { + :imageable=>["document", "product"] + } + actual = JSONAPI::Utils::PolymorphicTypesLookup.build_polymorphic_types_lookup_from_descendants + actual_keys = actual.keys.sort + assert_equal(actual_keys, expected.keys.sort) + actual_keys.each do |actual_key| + actual_values = actual[actual_key].sort + expected_values = expected[actual_key].sort + assert_equal(actual_values, expected_values) + end + end +end From 1d5977bce0219c9dde2f59dbe96362dcf739c578 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 26 Jan 2024 12:32:38 -0500 Subject: [PATCH 26/34] Update the testing matrix (#1442) --- .docker/ruby_versions.txt | 8 ++++---- .github/workflows/ruby.yml | 15 ++++----------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/.docker/ruby_versions.txt b/.docker/ruby_versions.txt index 61f4d7632..ce5156c62 100644 --- a/.docker/ruby_versions.txt +++ b/.docker/ruby_versions.txt @@ -1,4 +1,4 @@ -2.7.7 -3.0.5 -3.1.3 -3.2.1 +3.0.6 +3.1.4 +3.2.3 +3.3.0 diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 858c61294..983d39913 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -38,28 +38,21 @@ jobs: fail-fast: false matrix: ruby: + - '3.3' - '3.2' - '3.1' - '3.0' - - '2.7' rails: - '7.1' - '7.0' - '6.1' - - '6.0' database_url: - sqlite3:test_db - postgresql://postgres:password@localhost:5432/test - mysql2://root:root@127.0.0.1:3306/test - exclude: - - ruby: '3.2' - rails: '6.0' - - ruby: '3.1' - rails: '6.0' - - ruby: '3.1' - rails: '5.1' - - ruby: '3.0' - rails: '6.0' +# exclude: +# - ruby: '3.1' +# rails: '6.0' env: RAILS_VERSION: ${{ matrix.rails }} DATABASE_URL: ${{ matrix.database_url }} From 7a1fd3256941d97578b7036996bfc3e103eabf25 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 26 Jan 2024 12:37:18 -0500 Subject: [PATCH 27/34] Polymorphic types override per relationship (#1440) * Add warning about disabling eager loading * Fix overriding polymorphic types on a relationship --- lib/jsonapi/active_relation_retrieval.rb | 4 +-- lib/jsonapi/configuration.rb | 4 +++ lib/jsonapi/relationship.rb | 36 +++++++++++++++--------- lib/jsonapi/resources/railtie.rb | 8 ++++-- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/lib/jsonapi/active_relation_retrieval.rb b/lib/jsonapi/active_relation_retrieval.rb index af4e62d1b..2ee2e0261 100644 --- a/lib/jsonapi/active_relation_retrieval.rb +++ b/lib/jsonapi/active_relation_retrieval.rb @@ -262,7 +262,7 @@ def find_fragments(filters, options = {}) def find_related_fragments(source_fragment, relationship, options = {}) if relationship.polymorphic? # && relationship.foreign_key_on == :self source_resource_klasses = if relationship.foreign_key_on == :self - relationship.class.polymorphic_types(relationship.name).collect do |polymorphic_type| + relationship.polymorphic_types.collect do |polymorphic_type| resource_klass_for(polymorphic_type) end else @@ -284,7 +284,7 @@ def find_related_fragments(source_fragment, relationship, options = {}) def find_included_fragments(source_fragments, relationship, options) if relationship.polymorphic? # && relationship.foreign_key_on == :self source_resource_klasses = if relationship.foreign_key_on == :self - relationship.class.polymorphic_types(relationship.name).collect do |polymorphic_type| + relationship.polymorphic_types.collect do |polymorphic_type| resource_klass_for(polymorphic_type) end else diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index e739b722b..dce8b9e3e 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -13,6 +13,7 @@ class Configuration :warn_on_route_setup_issues, :warn_on_missing_routes, :warn_on_performance_issues, + :warn_on_eager_loading_disabled, :default_allow_include_to_one, :default_allow_include_to_many, :allow_sort, @@ -67,6 +68,7 @@ def initialize self.warn_on_route_setup_issues = true self.warn_on_missing_routes = true self.warn_on_performance_issues = true + self.warn_on_eager_loading_disabled = true # :none, :offset, :paged, or a custom paginator name self.default_paginator = :none @@ -326,6 +328,8 @@ def allow_include=(allow_include) attr_writer :warn_on_performance_issues + attr_writer :warn_on_eager_loading_disabled + attr_writer :use_relationship_reflection attr_writer :resource_cache diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 1378d8e7c..51be3719e 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -19,10 +19,11 @@ def initialize(name, options = {}) @parent_resource = options[:parent_resource] @relation_name = options[:relation_name] @polymorphic = options.fetch(:polymorphic, false) == true - @polymorphic_types = options[:polymorphic_types] + @polymorphic_types_override = options[:polymorphic_types] + if options[:polymorphic_relations] JSONAPI.configuration.deprecate('Use polymorphic_types instead of polymorphic_relations') - @polymorphic_types ||= options[:polymorphic_relations] + @polymorphic_types_override ||= options[:polymorphic_relations] end use_related_resource_records_for_joins_default = if options[:relation_name] @@ -86,16 +87,27 @@ def inverse_relationship @inverse_relationship end - def self.polymorphic_types(name) - ::JSONAPI::Utils::PolymorphicTypesLookup.polymorphic_types(name) + def polymorphic_types + return @polymorphic_types if @polymorphic_types + + types = @polymorphic_types_override + types ||= ::JSONAPI::Utils::PolymorphicTypesLookup.polymorphic_types(_relation_name) + + @polymorphic_types = types&.map { |t| t.to_s.pluralize } || [] + + if @polymorphic_types.blank? + warn "[POLYMORPHIC TYPE] No polymorphic types set or found for #{parent_resource.name} #{_relation_name}" + end + + @polymorphic_types end def resource_types - if polymorphic? && belongs_to? - @polymorphic_types ||= self.class.polymorphic_types(_relation_name).collect { |t| t.pluralize } - else - [resource_klass._type.to_s.pluralize] - end + @resource_types ||= if polymorphic? + polymorphic_types + else + [resource_klass._type.to_s.pluralize] + end end def type @@ -191,11 +203,7 @@ def polymorphic_type end def setup_implicit_relationships_for_polymorphic_types(exclude_linkage_data: true) - types = self.class.polymorphic_types(_relation_name) - unless types.present? - warn "[POLYMORPHIC TYPE] No polymorphic types found for #{parent_resource.name} #{_relation_name}" - return - end + types = polymorphic_types types.each do |type| parent_resource.has_one(type.to_s.underscore.singularize, diff --git a/lib/jsonapi/resources/railtie.rb b/lib/jsonapi/resources/railtie.rb index 02050879b..920e88b1f 100644 --- a/lib/jsonapi/resources/railtie.rb +++ b/lib/jsonapi/resources/railtie.rb @@ -19,8 +19,12 @@ class Railtie < ::Rails::Railtie } end - initializer "jsonapi_resources.initialize", after: :initialize do - JSONAPI::Utils::PolymorphicTypesLookup.polymorphic_types_lookup_clear! + config.before_initialize do + if !Rails.application.config.eager_load && ::JSONAPI::configuration.warn_on_eager_loading_disabled + warn 'WARNING: jsonapi-resources may not load polymorphic types when Rails `eager_load` is disabled. ' \ + 'Polymorphic types may be set per relationship . This warning may be disable in the configuration ' \ + 'by setting `warn_on_eager_loading_disabled` to false.' + end end end end From e55371a223e84c2df1fc9058a8a612bbe8a8e838 Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Fri, 26 Jan 2024 13:11:05 -0500 Subject: [PATCH 28/34] Fix issue with relationship sorts due to missing join_manager (#1443) --- lib/jsonapi/active_relation_retrieval_v09.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/active_relation_retrieval_v09.rb b/lib/jsonapi/active_relation_retrieval_v09.rb index 0ca023ea3..2c526f0d9 100644 --- a/lib/jsonapi/active_relation_retrieval_v09.rb +++ b/lib/jsonapi/active_relation_retrieval_v09.rb @@ -107,7 +107,12 @@ def find_fragments(filters, options = {}) filters: filters, sort_criteria: sort_criteria) - options[:_relation_helper_options] = { join_manager: join_manager, sort_fields: [] } + options[:_relation_helper_options] = { + context: context, + join_manager: join_manager, + sort_fields: [] + } + include_directives = options[:include_directives] records = records(options) @@ -116,7 +121,7 @@ def find_fragments(filters, options = {}) records = filter_records(records, filters, options) - records = sort_records(records, order_options, context) + records = sort_records(records, order_options, options) records = apply_pagination(records, options[:paginator], order_options) From fa3e05912798a45610ae22f11d049366c8c08dab Mon Sep 17 00:00:00 2001 From: Adam Kiczula Date: Thu, 8 Feb 2024 14:15:11 -0600 Subject: [PATCH 29/34] fix: add railtie to clear cache after class changes (#1448) --- lib/jsonapi/resources/railtie.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/jsonapi/resources/railtie.rb b/lib/jsonapi/resources/railtie.rb index 920e88b1f..7074aba00 100644 --- a/lib/jsonapi/resources/railtie.rb +++ b/lib/jsonapi/resources/railtie.rb @@ -26,6 +26,10 @@ class Railtie < ::Rails::Railtie 'by setting `warn_on_eager_loading_disabled` to false.' end end + config.to_prepare do + ::JSONAPI::Resource._clear_resource_type_to_klass_cache + ::JSONAPI::Resource._clear_model_to_resource_type_cache + end end end end From 3509c4a2d36ef7bff80d1f9f0fe62c8cc41b083f Mon Sep 17 00:00:00 2001 From: Larry Gebhardt Date: Wed, 17 Apr 2024 10:42:55 -0400 Subject: [PATCH 30/34] Warn on missing inverse relationships (#1451) --- lib/jsonapi/active_relation_retrieval.rb | 25 +++++++++--------- lib/jsonapi/active_relation_retrieval_v09.rb | 4 ++- lib/jsonapi/active_relation_retrieval_v10.rb | 12 +++------ lib/jsonapi/configuration.rb | 11 ++++++++ lib/jsonapi/link_builder.rb | 8 +++--- lib/jsonapi/relationship.rb | 27 ++++++++++++++++---- lib/jsonapi/resource_identity.rb | 2 +- test/unit/serializer/link_builder_test.rb | 2 +- 8 files changed, 59 insertions(+), 32 deletions(-) diff --git a/lib/jsonapi/active_relation_retrieval.rb b/lib/jsonapi/active_relation_retrieval.rb index 2ee2e0261..88f886c89 100644 --- a/lib/jsonapi/active_relation_retrieval.rb +++ b/lib/jsonapi/active_relation_retrieval.rb @@ -304,10 +304,10 @@ def find_included_fragments(source_fragments, relationship, options) end def find_related_fragments_from_inverse(source, source_relationship, options, connect_source_identity) - relationship = source_relationship.resource_klass._relationship(source_relationship.inverse_relationship) - raise "missing inverse relationship" unless relationship.present? + inverse_relationship = source_relationship._inverse_relationship + return {} if inverse_relationship.blank? - parent_resource_klass = relationship.resource_klass + parent_resource_klass = inverse_relationship.resource_klass include_directives = options.fetch(:include_directives, {}) @@ -332,7 +332,7 @@ def find_related_fragments_from_inverse(source, source_relationship, options, co end join_manager = ActiveRelation::JoinManager.new(resource_klass: self, - source_relationship: relationship, + source_relationship: inverse_relationship, relationships: linkage_relationships.collect(&:name), sort_criteria: sort_criteria, filters: filters) @@ -352,7 +352,7 @@ def find_related_fragments_from_inverse(source, source_relationship, options, co if options[:cache] # This alias is going to be resolve down to the model's table name and will not actually be an alias resource_table_alias = self._table_name - parent_table_alias = join_manager.join_details_by_relationship(relationship)[:alias] + parent_table_alias = join_manager.join_details_by_relationship(inverse_relationship)[:alias] pluck_fields = [ sql_field_with_alias(resource_table_alias, self._primary_key), @@ -400,7 +400,7 @@ def find_related_fragments_from_inverse(source, source_relationship, options, co fragments[rid].add_related_from(parent_rid) if connect_source_identity - fragments[rid].add_related_identity(relationship.name, parent_rid) + fragments[rid].add_related_identity(inverse_relationship.name, parent_rid) end attributes_offset = 2 @@ -452,7 +452,7 @@ def find_related_fragments_from_inverse(source, source_relationship, options, co end end - parent_table_alias = join_manager.join_details_by_relationship(relationship)[:alias] + parent_table_alias = join_manager.join_details_by_relationship(inverse_relationship)[:alias] source_field = sql_field_with_fixed_alias(parent_table_alias, parent_resource_klass._primary_key, "jr_source_id") records = records.select(concat_table_field(_table_name, Arel.star), source_field) @@ -471,7 +471,7 @@ def find_related_fragments_from_inverse(source, source_relationship, options, co parent_rid = JSONAPI::ResourceIdentity.new(parent_resource_klass, resource._model.attributes['jr_source_id']) if connect_source_identity - fragments[rid].add_related_identity(relationship.name, parent_rid) + fragments[rid].add_related_identity(inverse_relationship.name, parent_rid) end fragments[rid].add_related_from(parent_rid) @@ -503,15 +503,16 @@ def count_related(source, relationship, options = {}) end def count_related_from_inverse(source_resource, source_relationship, options = {}) - relationship = source_relationship.resource_klass._relationship(source_relationship.inverse_relationship) + inverse_relationship = source_relationship._inverse_relationship + return -1 if inverse_relationship.blank? - related_klass = relationship.resource_klass + related_klass = inverse_relationship.resource_klass filters = options.fetch(:filters, {}) # Joins in this case are related to the related_klass join_manager = ActiveRelation::JoinManager.new(resource_klass: self, - source_relationship: relationship, + source_relationship: inverse_relationship, filters: filters) records = apply_request_settings_to_records(records: records(options), @@ -521,7 +522,7 @@ def count_related_from_inverse(source_resource, source_relationship, options = { filters: filters, options: options) - related_alias = join_manager.join_details_by_relationship(relationship)[:alias] + related_alias = join_manager.join_details_by_relationship(inverse_relationship)[:alias] records = records.select(Arel.sql("#{concat_table_field(related_alias, related_klass._primary_key)}")) diff --git a/lib/jsonapi/active_relation_retrieval_v09.rb b/lib/jsonapi/active_relation_retrieval_v09.rb index 2c526f0d9..ca72a7b3a 100644 --- a/lib/jsonapi/active_relation_retrieval_v09.rb +++ b/lib/jsonapi/active_relation_retrieval_v09.rb @@ -287,7 +287,9 @@ def load_resources_to_fragments(fragments, related_resources, source_resource, s if source_resource source_resource.add_related_identity(source_relationship.name, related_resource.identity) fragment.add_related_from(source_resource.identity) - fragment.add_related_identity(source_relationship.inverse_relationship, source_resource.identity) + + inverse_relationship = source_relationship._inverse_relationship + fragment.add_related_identity(inverse_relationship.name, source_resource.identity) if inverse_relationship.present? end end end diff --git a/lib/jsonapi/active_relation_retrieval_v10.rb b/lib/jsonapi/active_relation_retrieval_v10.rb index f48c54c6c..0e3a77601 100644 --- a/lib/jsonapi/active_relation_retrieval_v10.rb +++ b/lib/jsonapi/active_relation_retrieval_v10.rb @@ -464,10 +464,8 @@ def find_related_monomorphic_fragments(source_fragments, relationship, options, end if connect_source_identity - related_relationship = resource_klass._relationship(relationship.inverse_relationship) - if related_relationship - fragments[rid].add_related_identity(related_relationship.name, source_rid) - end + inverse_relationship = relationship._inverse_relationship + fragments[rid].add_related_identity(inverse_relationship.name, source_rid) if inverse_relationship``.present? end end @@ -598,10 +596,8 @@ def find_related_polymorphic_fragments(source_fragments, relationship, options, related_fragments[rid].add_related_from(source_rid) if connect_source_identity - related_relationship = related_klass._relationship(relationship.inverse_relationship) - if related_relationship - related_fragments[rid].add_related_identity(related_relationship.name, source_rid) - end + inverse_relationship = relationship._inverse_relationship + related_fragments[rid].add_related_identity(inverse_relationship.name, source_rid) if inverse_relationship.present? end relation_position = relation_positions[row[2].underscore.pluralize] diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index dce8b9e3e..bfaeb5fdd 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -14,6 +14,8 @@ class Configuration :warn_on_missing_routes, :warn_on_performance_issues, :warn_on_eager_loading_disabled, + :warn_on_missing_relationships, + :raise_on_missing_relationships, :default_allow_include_to_one, :default_allow_include_to_many, :allow_sort, @@ -69,6 +71,11 @@ def initialize self.warn_on_missing_routes = true self.warn_on_performance_issues = true self.warn_on_eager_loading_disabled = true + self.warn_on_missing_relationships = true + # If this is set to true an error will be raised if a resource is found to be missing a relationship + # If this is set to false a warning will be logged (see warn_on_missing_relationships) and related resouces for + # this relationship will not be included in the response. + self.raise_on_missing_relationships = false # :none, :offset, :paged, or a custom paginator name self.default_paginator = :none @@ -330,6 +337,10 @@ def allow_include=(allow_include) attr_writer :warn_on_eager_loading_disabled + attr_writer :warn_on_missing_relationships + + attr_writer :raise_on_missing_relationships + attr_writer :use_relationship_reflection attr_writer :resource_cache diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index 63b160fc6..dd12c7a4d 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -34,7 +34,7 @@ def primary_resources_url @primary_resources_url_cached ||= "#{ base_url }#{ engine_mount_point }#{ primary_resources_path }" else if JSONAPI.configuration.warn_on_missing_routes && !@primary_resource_klass._warned_missing_route - warn "primary_resources_url for #{@primary_resource_klass} could not be generated" + warn "primary_resources_url for #{@primary_resource_klass.name} could not be generated" @primary_resource_klass._warned_missing_route = true end nil @@ -54,7 +54,7 @@ def relationships_related_link(source, relationship, query_params = {}) url else if JSONAPI.configuration.warn_on_missing_routes && !relationship._warned_missing_route - warn "related_link for #{relationship} could not be generated" + warn "related_link for #{relationship.display_name} could not be generated" relationship._warned_missing_route = true end nil @@ -66,7 +66,7 @@ def relationships_self_link(source, relationship) "#{ self_link(source) }/relationships/#{ route_for_relationship(relationship) }" else if JSONAPI.configuration.warn_on_missing_routes && !relationship._warned_missing_route - warn "self_link for #{relationship} could not be generated" + warn "self_link for #{relationship.display_name} could not be generated" relationship._warned_missing_route = true end nil @@ -78,7 +78,7 @@ def self_link(source) resource_url(source) else if JSONAPI.configuration.warn_on_missing_routes && !source.class._warned_missing_route - warn "self_link for #{source.class} could not be generated" + warn "self_link for #{source.class.name} could not be generated" source.class._warned_missing_route = true end nil diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index 51be3719e..8f7d0fb11 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -9,7 +9,7 @@ class Relationship attr_writer :allow_include - attr_accessor :_routed, :_warned_missing_route + attr_accessor :_routed, :_warned_missing_route, :_warned_missing_inverse_relationship def initialize(name, options = {}) @name = name.to_s @@ -47,6 +47,7 @@ def initialize(name, options = {}) @_routed = false @_warned_missing_route = false + @_warned_missing_inverse_relationship = false exclude_links(options.fetch(:exclude_links, JSONAPI.configuration.default_exclude_links)) @@ -162,6 +163,22 @@ def _relation_name @relation_name || @name end + def to_s + display_name + end + + def _inverse_relationship + @inverse_relationship_klass ||= self.resource_klass._relationship(self.inverse_relationship) + if @inverse_relationship_klass.blank? + message = "Missing inverse relationship detected for: #{self}" + warn message if JSONAPI.configuration.warn_on_missing_relationships && !@_warned_missing_inverse_relationship + @_warned_missing_inverse_relationship = true + + raise message if JSONAPI.configuration.raise_on_missing_relationships + end + @inverse_relationship_klass + end + class ToOne < Relationship attr_reader :foreign_key_on @@ -182,9 +199,9 @@ def initialize(name, options = {}) @polymorphic_type_relationship_for = options[:polymorphic_type_relationship_for] end - def to_s + def display_name # :nocov: useful for debugging - "#{parent_resource}.#{name}(#{belongs_to? ? 'BelongsToOne' : 'ToOne'})" + "#{parent_resource.name}.#{name}(#{belongs_to? ? 'BelongsToOne' : 'ToOne'})" # :nocov: end @@ -247,9 +264,9 @@ def initialize(name, options = {}) # end end - def to_s + def display_name # :nocov: useful for debugging - "#{parent_resource_klass}.#{name}(ToMany)" + "#{parent_resource.name}.#{name}(ToMany)" # :nocov: end diff --git a/lib/jsonapi/resource_identity.rb b/lib/jsonapi/resource_identity.rb index 58936d95e..f55d151c6 100644 --- a/lib/jsonapi/resource_identity.rb +++ b/lib/jsonapi/resource_identity.rb @@ -58,7 +58,7 @@ def <=>(other_identity) # Creates a string representation of the identifier. def to_s # :nocov: - "#{resource_klass}:#{id}" + "#{resource_klass.name}:#{id}" # :nocov: end end diff --git a/test/unit/serializer/link_builder_test.rb b/test/unit/serializer/link_builder_test.rb index fd502400d..f01efb4a2 100644 --- a/test/unit/serializer/link_builder_test.rb +++ b/test/unit/serializer/link_builder_test.rb @@ -248,7 +248,7 @@ def test_relationships_related_link_not_routed link = builder.relationships_related_link(source, relationship) assert_nil link end - assert_equal(err, "related_link for Api::Secret::PostResource.author(BelongsToOne) could not be generated\n") + assert_equal("related_link for Api::Secret::PostResource.author(BelongsToOne) could not be generated\n", err) # should only warn once builder = JSONAPI::LinkBuilder.new(config) From 05c10bd8be821c43aa4c5d2b0d0633b50a82cb2b Mon Sep 17 00:00:00 2001 From: lgebhardt Date: Thu, 18 Apr 2024 12:05:00 -0400 Subject: [PATCH 31/34] Bring in pr/1450 to join_manager_v10.rb --- .../active_relation/join_manager_v10.rb | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/jsonapi/active_relation/join_manager_v10.rb b/lib/jsonapi/active_relation/join_manager_v10.rb index 07ca09789..e89480132 100644 --- a/lib/jsonapi/active_relation/join_manager_v10.rb +++ b/lib/jsonapi/active_relation/join_manager_v10.rb @@ -72,7 +72,7 @@ def join_details_by_relationship(relationship) @join_details[segment] end - def self.get_join_arel_node(records, options = {}) + def self.get_join_arel_node(records, relationship, join_type, options = {}) init_join_sources = records.arel.join_sources init_join_sources_length = init_join_sources.length @@ -82,9 +82,27 @@ def self.get_join_arel_node(records, options = {}) if join_sources.length > init_join_sources_length last_join = (join_sources - init_join_sources).last else + # Try to find a pre-existing join for this table. + # We can get here if include_optional_linkage_data is true + # (or always_include_to_xxx_linkage_data), + # and the user's custom `records` method has already added that join. + # + # If we want a left join and there is already an inner/left join, + # then we can use that. + # If we want an inner join and there is alrady an inner join, + # then we can use that (but not a left join, since that doesn't filter things out). + valid_join_types = [Arel::Nodes::InnerJoin] + valid_join_types << Arel::Nodes::OuterJoin if join_type == :left + table_name = relationship.resource_klass._table_name + + last_join = join_sources.find { |j| + valid_join_types.any? { |t| j.is_a?(t) } && j.left.name == table_name + } + end + + if last_join.nil? # :nocov: warn "get_join_arel_node: No join added" - last_join = nil # :nocov: end @@ -162,7 +180,7 @@ def perform_joins(records, options) next end - records, join_node = self.class.get_join_arel_node(records, options) {|records, options| + records, join_node = self.class.get_join_arel_node(records, relationship, join_type, options) {|records, options| related_resource_klass.join_relationship( records: records, resource_type: related_resource_klass._type, From 9b9961d41e020fe21bf7f9e1c164ca2f68fe2f7d Mon Sep 17 00:00:00 2001 From: lgebhardt Date: Tue, 9 Jan 2024 15:24:14 -0500 Subject: [PATCH 32/34] Fix issue with extra `` in code --- lib/jsonapi/active_relation_retrieval_v10.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi/active_relation_retrieval_v10.rb b/lib/jsonapi/active_relation_retrieval_v10.rb index 0e3a77601..e2ee9b2c7 100644 --- a/lib/jsonapi/active_relation_retrieval_v10.rb +++ b/lib/jsonapi/active_relation_retrieval_v10.rb @@ -465,7 +465,7 @@ def find_related_monomorphic_fragments(source_fragments, relationship, options, if connect_source_identity inverse_relationship = relationship._inverse_relationship - fragments[rid].add_related_identity(inverse_relationship.name, source_rid) if inverse_relationship``.present? + fragments[rid].add_related_identity(inverse_relationship.name, source_rid) if inverse_relationship.present? end end From 42a11b7fc1694ad58fd04787a3cfb66e20104775 Mon Sep 17 00:00:00 2001 From: pareeohnos Date: Thu, 14 Nov 2024 18:16:17 +0000 Subject: [PATCH 33/34] Change method used to retrieve rack status (#1457) Fixes #1456 Co-authored-by: Adrian Hooper --- lib/jsonapi/error.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi/error.rb b/lib/jsonapi/error.rb index 12d65f585..158a93792 100644 --- a/lib/jsonapi/error.rb +++ b/lib/jsonapi/error.rb @@ -17,7 +17,7 @@ def initialize(options = {}) @source = options[:source] @links = options[:links] - @status = Rack::Utils::SYMBOL_TO_STATUS_CODE[options[:status]].to_s + @status = Rack::Utils.status_code(options[:status]).to_s @meta = options[:meta] end @@ -48,7 +48,7 @@ def update_with_overrides(error_object_overrides) if error_object_overrides[:status] # :nocov: - @status = Rack::Utils::SYMBOL_TO_STATUS_CODE[error_object_overrides[:status]].to_s + @status = Rack::Utils.status_code(error_object_overrides[:status]).to_s # :nocov: end @meta = error_object_overrides[:meta] || @meta From d3c094b46a38650e583f40adc86474827b606fc7 Mon Sep 17 00:00:00 2001 From: Adam Kiczula <713080+adamkiczula@users.noreply.github.com> Date: Wed, 20 Nov 2024 21:09:06 -0600 Subject: [PATCH 34/34] fix: validate type matches resource type for sparse fieldsets (#1461) --- lib/jsonapi/request.rb | 1 + .../jsonapi_request/jsonapi_request_test.rb | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/lib/jsonapi/request.rb b/lib/jsonapi/request.rb index 5e80ff96a..1653835f0 100644 --- a/lib/jsonapi/request.rb +++ b/lib/jsonapi/request.rb @@ -309,6 +309,7 @@ def parse_fields(resource_klass, fields) if type_resource.nil? fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) else + verify_type(type, type_resource) unless values.nil? valid_fields = type_resource.fields.collect { |key| format_key(key) } values.each do |field| diff --git a/test/unit/jsonapi_request/jsonapi_request_test.rb b/test/unit/jsonapi_request/jsonapi_request_test.rb index dc467a728..bbad9e46f 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -171,6 +171,30 @@ def test_parse_dasherized_with_underscored_include assert_equal 'iso_currency is not a valid includable relationship of expense-entries', request.errors[0].detail end + def test_parse_fields_singular + params = ActionController::Parameters.new( + { + controller: 'expense_entries', + action: 'index', + fields: {expense_entry: 'iso_currency'} + } + ) + + request = JSONAPI::Request.new( + params, + { + context: nil, + key_formatter: JSONAPI::Formatter.formatter_for(:underscored_key) + } + ) + + e = assert_raises JSONAPI::Exceptions::InvalidResource do + request.parse_fields(ExpenseEntryResource, params[:fields]) + end + refute e.errors.empty? + assert_equal 'expense_entry is not a valid resource.', e.errors[0].detail + end + def test_parse_fields_underscored params = ActionController::Parameters.new( {